Repository: sentrionic/Valkyrie
Branch: main
Commit: 5daec722cd19
Files: 291
Total size: 1.4 MB
Directory structure:
gitextract_j1xfpjp2/
├── .github/
│ └── workflows/
│ ├── release_build.yml
│ ├── server_ci.yml
│ ├── server_deploy.yml
│ ├── web_ci.yml
│ ├── website-e2e.yml
│ └── website_deploy.yml
├── .gitignore
├── LICENSE
├── README.md
├── server/
│ ├── .gitattributes
│ ├── .gitignore
│ ├── .idea/
│ │ ├── .gitignore
│ │ ├── ValkyrieGo.iml
│ │ ├── dataSources.xml
│ │ ├── modules.xml
│ │ └── vcs.xml
│ ├── Makefile
│ ├── config/
│ │ └── config.go
│ ├── data_sources.go
│ ├── docs/
│ │ ├── docs.go
│ │ ├── swagger.json
│ │ └── swagger.yaml
│ ├── e2e_test.go
│ ├── go.mod
│ ├── go.sum
│ ├── handler/
│ │ ├── account_handler.go
│ │ ├── account_handler_test.go
│ │ ├── auth_handler.go
│ │ ├── auth_handler_test.go
│ │ ├── bind_data.go
│ │ ├── channel_handler.go
│ │ ├── channel_handler_test.go
│ │ ├── friend_handler.go
│ │ ├── friend_handler_test.go
│ │ ├── guild_handler.go
│ │ ├── guild_handler_test.go
│ │ ├── handler.go
│ │ ├── member_handler.go
│ │ ├── member_handler_test.go
│ │ ├── message_handler.go
│ │ ├── message_handler_test.go
│ │ ├── middleware/
│ │ │ ├── auth_user.go
│ │ │ ├── auth_user_test.go
│ │ │ └── timeout.go
│ │ ├── mime_type.go
│ │ └── test_helpers.go
│ ├── injection.go
│ ├── main.go
│ ├── mocks/
│ │ ├── ChannelRepository.go
│ │ ├── ChannelService.go
│ │ ├── FileRepository.go
│ │ ├── FriendRepository.go
│ │ ├── FriendService.go
│ │ ├── GuildRepository.go
│ │ ├── GuildService.go
│ │ ├── MailRepository.go
│ │ ├── MessageRepository.go
│ │ ├── MessageService.go
│ │ ├── RedisRepository.go
│ │ ├── Request.go
│ │ ├── SocketService.go
│ │ ├── UserRepository.go
│ │ └── UserService.go
│ ├── model/
│ │ ├── app_constants.go
│ │ ├── apperrors/
│ │ │ ├── apperrors.go
│ │ │ └── httperrors.go
│ │ ├── base_model.go
│ │ ├── channel.go
│ │ ├── direct_message.go
│ │ ├── dm_member.go
│ │ ├── field_error.go
│ │ ├── fixture/
│ │ │ ├── channel.go
│ │ │ ├── faker.go
│ │ │ ├── guild.go
│ │ │ ├── message.go
│ │ │ ├── multipart.go
│ │ │ └── user.go
│ │ ├── friend.go
│ │ ├── friend_request.go
│ │ ├── guild.go
│ │ ├── interfaces.go
│ │ ├── invite.go
│ │ ├── member.go
│ │ ├── message.go
│ │ ├── user.go
│ │ └── ws_message.go
│ ├── repository/
│ │ ├── channel_repository.go
│ │ ├── file_repository.go
│ │ ├── friend_repository.go
│ │ ├── guild_repository.go
│ │ ├── mail_repository.go
│ │ ├── message_repository.go
│ │ ├── redis_repository.go
│ │ └── user_repository.go
│ ├── service/
│ │ ├── channel_service.go
│ │ ├── channel_service_test.go
│ │ ├── friend_service.go
│ │ ├── guild_service.go
│ │ ├── guild_service_test.go
│ │ ├── id_generator.go
│ │ ├── message_service.go
│ │ ├── message_service_test.go
│ │ ├── password.go
│ │ ├── password_test.go
│ │ ├── socket_service.go
│ │ ├── user_service.go
│ │ └── user_service_test.go
│ ├── static/
│ │ ├── asyncapi.yml
│ │ ├── index.html
│ │ └── js/
│ │ └── main.js
│ └── ws/
│ ├── actions.go
│ ├── client.go
│ ├── hub.go
│ └── room.go
└── web/
├── .eslintrc.json
├── .gitignore
├── .prettierrc
├── cypress/
│ ├── e2e/
│ │ ├── account.cy.ts
│ │ ├── channel.cy.ts
│ │ ├── friend.cy.ts
│ │ ├── guild.cy.ts
│ │ ├── member.cy.ts
│ │ └── message.cy.ts
│ ├── fixtures/
│ │ └── example.json
│ ├── plugins/
│ │ └── index.js
│ ├── support/
│ │ ├── commands.ts
│ │ ├── e2e.js
│ │ ├── index.d.ts
│ │ └── utils.ts
│ └── tsconfig.json
├── cypress.config.ts
├── package.json
├── public/
│ ├── _redirects
│ ├── index.html
│ ├── manifest.json
│ └── robots.txt
├── src/
│ ├── App.tsx
│ ├── components/
│ │ ├── common/
│ │ │ ├── GuildPills.tsx
│ │ │ ├── InputField.tsx
│ │ │ ├── Logo.tsx
│ │ │ └── NotificationIcon.tsx
│ │ ├── items/
│ │ │ ├── ChannelListItem.tsx
│ │ │ ├── DMListItem.tsx
│ │ │ ├── FriendsListItem.tsx
│ │ │ ├── GuildListItem.tsx
│ │ │ ├── MemberListItem.tsx
│ │ │ ├── NotificationListItem.tsx
│ │ │ ├── RequestListItem.tsx
│ │ │ ├── VoiceChannelItem.tsx
│ │ │ ├── css/
│ │ │ │ └── ContextMenu.css
│ │ │ └── message/
│ │ │ ├── Message.tsx
│ │ │ └── MessageContent.tsx
│ │ ├── layouts/
│ │ │ ├── AccountBar.tsx
│ │ │ ├── AppLayout.tsx
│ │ │ ├── LandingLayout.tsx
│ │ │ ├── VoiceBar.tsx
│ │ │ ├── guild/
│ │ │ │ ├── ChannelHeader.tsx
│ │ │ │ ├── Channels.tsx
│ │ │ │ ├── GuildList.tsx
│ │ │ │ ├── MemberList.tsx
│ │ │ │ ├── VoiceChat.tsx
│ │ │ │ ├── chat/
│ │ │ │ │ ├── ChatGrid.tsx
│ │ │ │ │ ├── ChatScreen.tsx
│ │ │ │ │ ├── FileUploadButton.tsx
│ │ │ │ │ └── MessageInput.tsx
│ │ │ │ └── css/
│ │ │ │ ├── ChannelScrollerCSS.ts
│ │ │ │ ├── GuildScrollerCSS.ts
│ │ │ │ ├── MemberScrollerCSS.ts
│ │ │ │ └── MessageInput.css
│ │ │ └── home/
│ │ │ ├── DMHeader.tsx
│ │ │ ├── DMSidebar.tsx
│ │ │ ├── css/
│ │ │ │ └── dmScrollerCSS.ts
│ │ │ └── dashboard/
│ │ │ ├── FriendsDashboard.tsx
│ │ │ ├── FriendsList.tsx
│ │ │ ├── FriendsListHeader.tsx
│ │ │ └── PendingList.tsx
│ │ ├── menus/
│ │ │ ├── GuildMenu.tsx
│ │ │ ├── MemberContextMenu.tsx
│ │ │ ├── StyledMenuItem.tsx
│ │ │ └── StyledMenuList.tsx
│ │ ├── modals/
│ │ │ ├── AddFriendModal.tsx
│ │ │ ├── AddGuildModal.tsx
│ │ │ ├── ChangePasswordModal.tsx
│ │ │ ├── ChannelSettingsModal.tsx
│ │ │ ├── CreateChannelModal.tsx
│ │ │ ├── CropImageModal.tsx
│ │ │ ├── DeleteMessageModal.tsx
│ │ │ ├── EditMemberModal.tsx
│ │ │ ├── EditMessageModal.tsx
│ │ │ ├── GuildSettingsModal.tsx
│ │ │ ├── InviteModal.tsx
│ │ │ ├── ModActionModal.tsx
│ │ │ └── RemoveFriendModal.tsx
│ │ └── sections/
│ │ ├── AddGuildIcon.tsx
│ │ ├── DMPlaceholder.tsx
│ │ ├── DateDivider.tsx
│ │ ├── Footer.tsx
│ │ ├── FriendsListButton.tsx
│ │ ├── GlobalState.tsx
│ │ ├── Hero.tsx
│ │ ├── HomeIcon.tsx
│ │ ├── NavBar.tsx
│ │ ├── OnlineLabel.tsx
│ │ ├── StartMessages.tsx
│ │ ├── StyledTooltip.tsx
│ │ └── UserPopover.tsx
│ ├── index.tsx
│ ├── lib/
│ │ ├── api/
│ │ │ ├── dtos/
│ │ │ │ ├── AuthInput.ts
│ │ │ │ ├── ChannelInput.ts
│ │ │ │ ├── GuildInput.ts
│ │ │ │ ├── GuildMemberInput.ts
│ │ │ │ ├── InviteInput.ts
│ │ │ │ └── UserInput.ts
│ │ │ ├── getSocket.ts
│ │ │ ├── handler/
│ │ │ │ ├── account.ts
│ │ │ │ ├── auth.ts
│ │ │ │ ├── channel.ts
│ │ │ │ ├── dm.ts
│ │ │ │ ├── guilds.ts
│ │ │ │ ├── members.ts
│ │ │ │ └── messages.ts
│ │ │ ├── setupAxios.ts
│ │ │ └── ws/
│ │ │ ├── useChannelSocket.ts
│ │ │ ├── useDMSocket.ts
│ │ │ ├── useFriendSocket.ts
│ │ │ ├── useGuildSocket.ts
│ │ │ ├── useMemberSocket.ts
│ │ │ ├── useMessageSocket.ts
│ │ │ ├── useRequestSocket.ts
│ │ │ └── useVoiceSocket.ts
│ │ ├── models/
│ │ │ ├── account.ts
│ │ │ ├── channel.ts
│ │ │ ├── dm.ts
│ │ │ ├── fieldError.ts
│ │ │ ├── friend.ts
│ │ │ ├── guild.ts
│ │ │ ├── member.ts
│ │ │ ├── message.ts
│ │ │ ├── routerProps.ts
│ │ │ └── voice.ts
│ │ ├── stores/
│ │ │ ├── channelStore.ts
│ │ │ ├── homeStore.ts
│ │ │ ├── settingsStore.ts
│ │ │ ├── userStore.ts
│ │ │ └── voiceStore.ts
│ │ └── utils/
│ │ ├── cropImage.ts
│ │ ├── dateUtils.ts
│ │ ├── hooks/
│ │ │ ├── useGetCurrentChannel.ts
│ │ │ ├── useGetCurrentDM.ts
│ │ │ ├── useGetCurrentGuild.ts
│ │ │ ├── useGetFriend.ts
│ │ │ └── useVoiceChat.ts
│ │ ├── querykeys.ts
│ │ ├── theme.ts
│ │ ├── toErrorMap.ts
│ │ └── validation/
│ │ ├── auth.schema.ts
│ │ ├── channel.schema.ts
│ │ ├── guild.schema.ts
│ │ ├── member.schema.ts
│ │ └── message.schema.ts
│ ├── react-app-env.d.ts
│ ├── routes/
│ │ ├── AuthRoute.tsx
│ │ ├── ForgotPassword.tsx
│ │ ├── Home.tsx
│ │ ├── Invite.tsx
│ │ ├── Landing.tsx
│ │ ├── Login.tsx
│ │ ├── Register.tsx
│ │ ├── ResetPassword.tsx
│ │ ├── Routes.tsx
│ │ ├── Settings.tsx
│ │ └── ViewGuild.tsx
│ ├── setupTests.ts
│ └── tests/
│ ├── fixture/
│ │ ├── accountFixture.ts
│ │ ├── channelFixtures.ts
│ │ ├── dmFixtures.ts
│ │ ├── friendFixture.ts
│ │ ├── guildFixtures.ts
│ │ ├── memberFixtures.ts
│ │ ├── messageFixtures.ts
│ │ └── requestFixtures.ts
│ ├── queries/
│ │ ├── account.test.tsx
│ │ ├── channel.test.tsx
│ │ ├── dm.test.tsx
│ │ ├── friend.test.tsx
│ │ ├── guild.test.tsx
│ │ ├── member.test.tsx
│ │ ├── message.test.tsx
│ │ └── request.test.tsx
│ └── testUtils.tsx
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/release_build.yml
================================================
name: Release Server Binary
on:
release:
types: [created]
jobs:
releases-matrix:
name: Release Go Binary
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, windows, darwin]
goarch: ['386', amd64]
exclude:
- goarch: '386'
goos: darwin
steps:
- uses: actions/checkout@v3
- uses: wangyoucao577/go-release-action@v1.38
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
project_path: './server'
binary_name: 'valkyrie-server'
md5sum: false
extra_files: ./server/.env.example README.md
================================================
FILE: .github/workflows/server_ci.yml
================================================
name: Test & Lint
on:
push:
paths:
- 'server/**'
pull_request:
paths:
- 'server/**'
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v4
with:
go-version: 1.20
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout=5m
working-directory: ./server
test:
name: Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:alpine
env:
POSTGRES_USER: root
POSTGRES_PASSWORD: secret
POSTGRES_DB: valkyrie
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: 'redis:alpine'
ports:
- '6379:6379'
volumes:
- 'redisdata:/data'
steps:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.20
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Run Unit Tests
working-directory: ./server
run: make test
- name: Run E2E
run: make e2e
working-directory: ./server
env:
DATABASE_URL: postgresql://root:secret@localhost:5432/valkyrie?sslmode=disable
HANDLER_TIMEOUT: 5
MAX_BODY_BYTES: 4194304
REDIS_URL: redis://localhost:6379
SECRET: jmaijopspahisodphiasdhiahiopsdhoiasdg8a89sdta08sdtg8aosdou
CORS_ORIGIN: origin
================================================
FILE: .github/workflows/server_deploy.yml
================================================
# name: Deploy [API]
# on:
# workflow_run:
# workflows: ['Test & Lint']
# branches: [main]
# types: [completed]
# jobs:
# on-success:
# runs-on: ubuntu-latest
# if: ${{ github.event.workflow_run.conclusion == 'success' }}
# steps:
# - uses: actions/checkout@v3
# - uses: akhileshns/heroku-deploy@v3.12.12
# with:
# heroku_api_key: ${{secrets.HEROKU_API_KEY}}
# heroku_app_name: ${{secrets.HEROKU_APP_NAME}}
# heroku_email: ${{secrets.HEROKU_EMAIL}}
# appdir: 'server'
================================================
FILE: .github/workflows/web_ci.yml
================================================
name: Test & Lint - Web
on:
push:
paths:
- 'web/**'
pull_request:
paths:
- 'web/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: '20.x'
cache: 'yarn'
cache-dependency-path: web/yarn.lock
- run: cd web && yarn install
- run: cd web && yarn compile
- run: cd web && yarn lint
- run: cd web && yarn test --watchAll=false
================================================
FILE: .github/workflows/website-e2e.yml
================================================
name: Website E2E
on:
workflow_run:
workflows: ['Test & Lint - Web']
branches: [main]
types: [completed]
jobs:
cypress-run:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:alpine
env:
POSTGRES_USER: root
POSTGRES_PASSWORD: secret
POSTGRES_DB: valkyrie
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: 'redis:alpine'
ports:
- '6379:6379'
volumes:
- 'redisdata:/data'
steps:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 1.20
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Run Server
run: go run github.com/sentrionic/valkyrie &
working-directory: ./server
env:
DATABASE_URL: postgresql://root:secret@localhost:5432/valkyrie?sslmode=disable
HANDLER_TIMEOUT: 5
MAX_BODY_BYTES: 4194304
REDIS_URL: redis://localhost:6379
SECRET: jmaijopspahisodphiasdhiahiopsdhoiasdg8a89sdta08sdtg8aosdou
PORT: 4000
CORS_ORIGIN: http://localhost:3000
GIN_MODE: release
- name: Cypress run
uses: cypress-io/github-action@v4
with:
install-command: yarn
start: yarn start
wait-on: http://localhost:3000
browser: chrome
working-directory: ./web
headless: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REACT_APP_API: http://localhost:4000
REACT_APP_WS: ws://http://localhost:4000/ws
================================================
FILE: .github/workflows/website_deploy.yml
================================================
# name: Website Deploy
# on:
# workflow_run:
# workflows: ['Website E2E']
# branches: [main]
# types: [completed]
# jobs:
# on-success:
# runs-on: ubuntu-latest
# if: ${{ github.event.workflow_run.conclusion == 'success' }}
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
# - name: Use Node.js 18.x
# uses: actions/setup-node@v3
# with:
# node-version: '18.x'
# cache: 'yarn'
# cache-dependency-path: web/yarn.lock
# - run: yarn install
# working-directory: ./web
# - run: yarn build --if-present
# working-directory: ./web
# - name: Deploy to netlify
# uses: netlify/actions/cli@master
# env:
# NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
# NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
# with:
# args: deploy --dir=web/build --prod
# secrets: '["NETLIFY_AUTH_TOKEN", "NETLIFY_SITE_ID"]'
================================================
FILE: .gitignore
================================================
# Created by https://www.toptal.com/developers/gitignore/api/webstorm
# Edit at https://www.toptal.com/developers/gitignore?templates=webstorm
### WebStorm ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### WebStorm Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
.idea/**/sonarlint/
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
.idea/**/sonarIssues.xml
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
.idea/$CACHE_FILE$
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
.idea/codestream.xml
# End of https://www.toptal.com/developers/gitignore/api/webstorm
# Local Netlify folder
.netlify
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021 sentrionic
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
[](https://goreportcard.com/report/github.com/sentrionic/Valkyrie)
# Valkyrie
A [Discord](https://discord.com) clone using [React](https://reactjs.org/) and [Go](https://golang.org/).
**Notes:**
- The design does not fully match the current design of Discord anymore.
- For the old [Socket.io](https://socket.io/) stack using [NestJS](https://nestjs.com/) check out the [v1](https://github.com/sentrionic/Valkyrie/tree/v1) branch.
## Video
https://user-images.githubusercontent.com/38354571/137365365-a7fe91d6-51d7-4739-8742-f68517223f8f.mp4
## Features
- Message, Channel, Server CRUD
- Authentication using Express Sessions
- Channel / Websocket Member Protection
- Realtime Events
- File Upload (Avatar, Icon, Messages) to S3
- Direct Messaging
- Private Channels
- Friend System
- Notification System
- Basic Moderation for the guild owner (delete messages, kick & ban members)
- Basic Voice Chat (one voice channel per guild + mute & deafen)
## Stack
### Server
- [Gin](https://gin-gonic.com/) for the HTTP server
- [Gorilla Websockets](https://github.com/gorilla/websocket) for WS communication
- [Gorm](https://gorm.io/) as the database ORM
- PostgreSQL to save all data
- Redis for storing sessions and reset tokens
- S3 for storing files and Gmail for sending emails
### Web
- React with [Chakra UI](https://chakra-ui.com/)
- [React Query](https://react-query.tanstack.com/) & [Zustand](https://github.com/pmndrs/zustand) for state management
- [Typescript](https://www.typescriptlang.org/)
For the mobile app using Flutter check out [ValkyrieApp](https://github.com/sentrionic/ValkyrieApp)
---
## Installation
### Server
If you are familiar with `make`, take a look at the `Makefile` to quickly setup the following steps
or alternatively copy the commands into your CLI.
1. Install Docker and get the Postgresql and Redis containers (`make postgres` && `make redis`)
2. Start both containers (`make start`) and create a DB (`make createdb`)
3. Install the latest version of Go and get all the dependencies (`go mod tidy`)
4. Rename `.env.example` to `.env` and fill in the values
- `Required`
PORT=4000
DATABASE_URL=postgresql://:@localhost:5432/valkyrie
REDIS_URL=redis://localhost:6379
CORS_ORIGIN=http://localhost:3000
SECRET=SUPERSECRET
HANDLER_TIMEOUT=5
MAX_BODY_BYTES=4194304 # 4MB in Bytes = 4 * 1024 * 1024
- `Optional: Not needed to run the app, but you won't be able to upload files or send emails.`
AWS_ACCESS_KEY=ACCESS_KEY
AWS_SECRET_ACCESS_KEY=SECRET_ACCESS_KEY
AWS_STORAGE_BUCKET_NAME=STORAGE_BUCKET_NAME
AWS_S3_REGION=S3_REGION
GMAIL_USER=GMAIL_USER
GMAIL_PASSWORD=GMAIL_PASSWORD
5. Run `go run github.com/sentrionic/valkyrie` to run the server
**Alternatively**: If you only want to run the backend without installing Go and all dependencies, you can download the pre compiled server from the [Release tab](https://github.com/sentrionic/Valkyrie/releases) instead. You will still need to follow the above steps 1, 2 and 4.
### Web
1. Install Node 20 or the LTS version of Node.
2. Install [yarn](https://classic.yarnpkg.com/lang/en/)
3. Run `yarn` to install the dependencies
4. Run `yarn start` to start the client
5. Go to `localhost:3000`
## Endpoints
Once the server is running go to `localhost:/swagger/index.html` to see all the HTTP endpoints
and `localhost:` for all the websockets events.
## Tests
All tests are run on all push and pull requests. Only if they are successful it will run the other Github Actions to automatically deploy the updates.
### Server
All routes in `handler` have tests written for them.
Function calls in the `service` directory that do not just delegate work to the repository have tests written for them.
Run `go test -v -cover ./service/... ./handler/...` (`make test`) to run all tests
Additionally this repository includes E2E tests for all successful requests. To run them you
have to have Postgres and Redis running in Docker and then run `go test github.com/sentrionic/valkyrie` (`make e2e`).
### Web
Most `useQuery` hooks have tests written for them.
To run them use `yarn test`.
Additionally [Cypress](https://www.cypress.io/) is used for E2E testing.
To run them you need to have the server and the client running.
After that run `yarn cypress` to open the test window.
**Note**: For unkown reasons websockets connection only randomly work during Cypress runs, which makes testing them impossible.
## Credits
[Ben Awad](https://github.com/benawad): The inital project is based on his Slack tutorial series and I always look at his repositories for inspiration.
[Jacob Goodwin](https://github.com/JacobSNGoodwin/memrizr): This backend is built upon his tutorial series and uses his backend structure.
[Jeroen de Kok](https://dev.to/jeroendk/building-a-simple-chat-application-with-websockets-in-go-and-vue-js-gao): The websockets structure is based on his tutorial.
[ericellb](https://github.com/ericellb/React-Discord-Clone): His repository helped me implement voice chat.
================================================
FILE: server/.gitattributes
================================================
*.html -linguist-detectable
*.css -linguist-detectable
*.js -linguist-detectable
================================================
FILE: server/.gitignore
================================================
# Created by https://www.toptal.com/developers/gitignore/api/go
# Edit at https://www.toptal.com/developers/gitignore?templates=go
.env
### Go ###
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
### Go Patch ###
/vendor/
/Godeps/
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# End of https://www.toptal.com/developers/gitignore/api/go
================================================
FILE: server/.idea/.gitignore
================================================
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
================================================
FILE: server/.idea/ValkyrieGo.iml
================================================
================================================
FILE: server/.idea/dataSources.xml
================================================
postgresql
true
org.postgresql.Driver
jdbc:postgresql://localhost:5432/valkyrie
$ProjectFileDir$
================================================
FILE: server/.idea/modules.xml
================================================
================================================
FILE: server/.idea/vcs.xml
================================================
================================================
FILE: server/Makefile
================================================
postgres:
docker run --name postgres -p 5432:5432 -e POSTGRES_USER=root -e POSTGRES_PASSWORD=password -d postgres:alpine
redis:
docker run --name redis -d -p 6379:6379 redis:alpine redis-server --save 60 1
createdb:
docker exec -it postgres createdb --username=root --owner=root valkyrie
dropdb:
docker exec -it postgres dropdb valkyrie
recreate:
make dropdb && make createdb
start:
docker start postgres && docker start redis
test:
go test -v -cover ./service/... ./handler/...
e2e:
go test github.com/sentrionic/valkyrie
lint:
golangci-lint run
mock:
mockery --all
build:
go build github.com/sentrionic/valkyrie
fmt:
go fmt github.com/sentrionic/...
swag:
swag init
workflow:
make fmt && make lint && make test
================================================
FILE: server/config/config.go
================================================
package config
import (
"context"
"github.com/sethvargo/go-envconfig"
)
type Config struct {
DatabaseUrl string `env:"DATABASE_URL,required"`
RedisUrl string `env:"REDIS_URL,required"`
Port string `env:"PORT,default=4000"`
SessionSecret string `env:"SECRET,required"`
Domain string `env:"DOMAIN"`
CorsOrigin string `env:"CORS_ORIGIN,required"`
AccessKey string `env:"AWS_ACCESS_KEY"`
SecretKey string `env:"SECRET_KEY"`
BucketName string `env:"BUCKET_NAME"`
Region string `env:"REGION"`
GmailUser string `env:"GMAIL_USER"`
GmailPassword string `env:"GMAIL_PASSWORD"`
HandlerTimeOut int64 `env:"HANDLER_TIMEOUT,default=5"`
MaxBodyBytes int64 `env:"MAX_BODY_BYTES,default=4194304"`
}
func LoadConfig(ctx context.Context) (config Config, err error) {
err = envconfig.Process(ctx, &config)
if err != nil {
return
}
return
}
================================================
FILE: server/data_sources.go
================================================
package main
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/redis/go-redis/v9"
"github.com/sentrionic/valkyrie/config"
"github.com/sentrionic/valkyrie/model"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"log"
)
type dataSources struct {
DB *gorm.DB
RedisClient *redis.Client
S3Session *session.Session
}
// InitDS establishes connections to fields in dataSources
func initDS(ctx context.Context, cfg config.Config) (*dataSources, error) {
log.Printf("Initializing data sources\n")
log.Printf("Connecting to Postgresql\n")
db, err := gorm.Open(postgres.Open(cfg.DatabaseUrl))
if err != nil {
return nil, fmt.Errorf("error opening db: %w", err)
}
// Migrate models and setup join tables
if err = db.AutoMigrate(
&model.User{},
&model.Guild{},
&model.Member{},
&model.Channel{},
&model.DMMember{},
&model.Message{},
&model.Attachment{},
&model.VCMember{},
); err != nil {
return nil, fmt.Errorf("error migrating models: %w", err)
}
if err = db.SetupJoinTable(&model.Guild{}, "Members", &model.Member{}); err != nil {
return nil, fmt.Errorf("error creating join table: %w", err)
}
if err = db.SetupJoinTable(&model.Guild{}, "VCMembers", &model.VCMember{}); err != nil {
return nil, fmt.Errorf("error creating join table: %w", err)
}
// Initialize redis connection
opt, err := redis.ParseURL(cfg.RedisUrl)
if err != nil {
return nil, fmt.Errorf("error parsing the redis url: %w", err)
}
log.Println("Connecting to Redis")
rdb := redis.NewClient(opt)
// verify redis connection
_, err = rdb.Ping(ctx).Result()
if err != nil {
return nil, fmt.Errorf("error connecting to redis: %w", err)
}
// Initialize S3 Session
sess, err := session.NewSession(
&aws.Config{
Credentials: credentials.NewStaticCredentials(
cfg.AccessKey,
cfg.SessionSecret,
"",
),
Region: aws.String(cfg.Region),
},
)
if err != nil {
return nil, fmt.Errorf("error creating s3 session: %w", err)
}
return &dataSources{
DB: db,
RedisClient: rdb,
S3Session: sess,
}, nil
}
// close to be used in graceful server shutdown
func (d *dataSources) close() error {
if err := d.RedisClient.Close(); err != nil {
return fmt.Errorf("error closing Redis Client: %w", err)
}
return nil
}
================================================
FILE: server/docs/docs.go
================================================
// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
// This file was generated by swaggo/swag
package docs
import (
"bytes"
"encoding/json"
"strings"
"github.com/alecthomas/template"
"github.com/swaggo/swag"
)
var doc = `{
"schemes": {{ marshal .Schemes }},
"swagger": "2.0",
"info": {
"description": "{{.Description}}",
"title": "{{.Title}}",
"contact": {},
"license": {
"name": "Apache 2.0"
},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/account": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Get Current User",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/User"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"put": {
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Update Current User",
"parameters": [
{
"description": "Update Account",
"name": "account",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/EditUser"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/change-password": {
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Change Current User's Password",
"parameters": [
{
"description": "Change Password",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/forgot-password": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Forgot Password Request",
"parameters": [
{
"description": "Forgot Password",
"name": "email",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ForgotPasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/login": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "User Login",
"parameters": [
{
"description": "Login account",
"name": "account",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/LoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/logout": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "User Logout",
"parameters": [
{
"description": "Login account",
"name": "account",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/LoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
}
}
}
},
"/account/me/friends": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Get Current User's Friends",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Friend"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/me/pending": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Get Current User's Friend Requests",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/FriendRequest"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/register": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Create an Account",
"parameters": [
{
"description": "Create account",
"name": "account",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/RegisterRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/reset-password": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Reset Password",
"parameters": [
{
"description": "Reset Password",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ResetPasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/{memberId}/friend": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Send Friend Request",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "memberId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Remove Friend",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "memberId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/{memberId}/friend/accept": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Accept Friend's Request",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "memberId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/{memberId}/friend/cancel": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Cancel Friend's Request",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "memberId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/me/dm": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Get User's DMs",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/DirectMessage"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{channelId}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Edit Channel",
"parameters": [
{
"type": "string",
"description": "Channel ID",
"name": "channelId",
"in": "path",
"required": true
},
{
"description": "Edit Channel",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ChannelRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{channelId}/dm": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Get or Create DM",
"parameters": [
{
"type": "string",
"description": "Member ID",
"name": "channelId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/DirectMessage"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{channelId}/members": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Get Members of the given Channel",
"parameters": [
{
"type": "string",
"description": "Channel ID",
"name": "channelId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{guildId}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Get Guild Channels",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Channel"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"post": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Create Channel",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Channel"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{id}": {
"delete": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Delete Channel",
"parameters": [
{
"type": "string",
"description": "Channel ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{id}/dm": {
"delete": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Close DM",
"parameters": [
{
"type": "string",
"description": "DM Channel ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Get Current User's Guilds",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/GuildResponse"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/create": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Create Guild",
"parameters": [
{
"description": "Create Guild",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/CreateGuildRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/GuildResponse"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/join": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Join Guild",
"parameters": [
{
"description": "Join Guild",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/JoinRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/GuildResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Edit Guild",
"parameters": [
{
"description": "Edit Guild",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/EditGuildRequest"
}
},
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Leave Guild",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/bans": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Get Guild Ban list",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/BanResponse"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"post": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Ban Member",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
},
{
"description": "Member ID",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MemberRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/SuccessResponse"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Unban Member",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
},
{
"description": "Member ID",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MemberRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/SuccessResponse"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/delete": {
"delete": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Delete Guild",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/invite": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Get Guild Invite",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Is Permanent",
"name": "isPermanent",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Delete all permanent invite links",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/kick": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Kick Member",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
},
{
"description": "Member ID",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MemberRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/SuccessResponse"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/member": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Get Member Settings",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/MemberSettings"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"put": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Edit Member Settings",
"parameters": [
{
"description": "Edit Member",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MemberSettingsRequest"
}
},
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/members": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Get Guild Members",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Member"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/messages/{channelId}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Messages"
],
"summary": "Get Channel Messages",
"parameters": [
{
"type": "string",
"description": "Channel ID",
"name": "channelId",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Cursor Pagination using the createdAt field",
"name": "cursor",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Message"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"post": {
"produces": [
"application/json"
],
"tags": [
"Messages"
],
"summary": "Create Messages",
"parameters": [
{
"type": "string",
"description": "Channel ID",
"name": "channelId",
"in": "path",
"required": true
},
{
"description": "Create Message",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MessageRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/messages/{messageId}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"Messages"
],
"summary": "Edit Messages",
"parameters": [
{
"type": "string",
"description": "Message ID",
"name": "messageId",
"in": "path",
"required": true
},
{
"description": "Edit Message",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MessageRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Messages"
],
"summary": "Delete Messages",
"parameters": [
{
"type": "string",
"description": "Message ID",
"name": "messageId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
}
},
"definitions": {
"Attachment": {
"type": "object",
"properties": {
"filename": {
"type": "string"
},
"filetype": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"BanResponse": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"ChangePasswordRequest": {
"type": "object",
"properties": {
"confirmNewPassword": {
"description": "Must be the same as the newPassword value.",
"type": "string"
},
"currentPassword": {
"type": "string"
},
"newPassword": {
"description": "Min 6, max 150 characters.",
"type": "string"
}
}
},
"Channel": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"hasNotification": {
"type": "boolean"
},
"id": {
"type": "string"
},
"isPublic": {
"type": "boolean"
},
"name": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"ChannelRequest": {
"type": "object",
"properties": {
"isPublic": {
"description": "Default is true",
"type": "boolean"
},
"members": {
"description": "Array of memberIds",
"type": "array",
"items": {
"type": "string"
}
},
"name": {
"description": "Channel Name. 3 to 30 character",
"type": "string"
}
}
},
"CreateGuildRequest": {
"type": "object",
"properties": {
"name": {
"description": "Guild Name. 3 to 30 characters",
"type": "string"
}
}
},
"DMUser": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"isFriend": {
"type": "boolean"
},
"isOnline": {
"type": "boolean"
},
"username": {
"type": "string"
}
}
},
"DirectMessage": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"user": {
"$ref": "#/definitions/DMUser"
}
}
},
"EditGuildRequest": {
"type": "object",
"properties": {
"icon": {
"description": "The old guild icon url if no new image is selected. Set to null to reset the guild icon",
"type": "string"
},
"image": {
"description": "image/png or image/jpeg",
"type": "string",
"format": "binary"
},
"name": {
"description": "Guild Name. 3 to 30 characters",
"type": "string"
}
}
},
"EditUser": {
"type": "object",
"properties": {
"email": {
"description": "Must be unique",
"type": "string"
},
"image": {
"description": "image/png or image/jpeg",
"type": "string",
"format": "binary"
},
"username": {
"description": "Min 3, max 30 characters.",
"type": "string"
}
}
},
"ErrorResponse": {
"type": "object",
"properties": {
"error": {
"$ref": "#/definitions/HttpError"
}
}
},
"ErrorsResponse": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"$ref": "#/definitions/FieldError"
}
}
}
},
"FieldError": {
"type": "object",
"properties": {
"field": {
"description": "The property containing the error",
"type": "string"
},
"message": {
"description": "The specific error message",
"type": "string"
}
}
},
"ForgotPasswordRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
}
}
},
"Friend": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"isOnline": {
"type": "boolean"
},
"username": {
"type": "string"
}
}
},
"FriendRequest": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"type": {
"description": "1: Incoming, 0: Outgoing",
"type": "integer",
"enum": [
0,
1
]
},
"username": {
"type": "string"
}
}
},
"GuildResponse": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"default_channel_id": {
"type": "string"
},
"hasNotification": {
"type": "boolean"
},
"icon": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"ownerId": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"HttpError": {
"type": "object",
"properties": {
"message": {
"description": "The specific error message",
"type": "string"
},
"type": {
"description": "The Http Response as a string",
"type": "string"
}
}
},
"JoinRequest": {
"type": "object",
"properties": {
"link": {
"type": "string"
}
}
},
"LoginRequest": {
"type": "object",
"properties": {
"email": {
"description": "Must be unique",
"type": "string"
},
"password": {
"description": "Min 6, max 150 characters.",
"type": "string"
}
}
},
"Member": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"isFriend": {
"type": "boolean"
},
"isOnline": {
"type": "boolean"
},
"nickname": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"MemberRequest": {
"type": "object",
"properties": {
"memberId": {
"type": "string"
}
}
},
"MemberSettings": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"nickname": {
"type": "string"
}
}
},
"MemberSettingsRequest": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"nickname": {
"type": "string"
}
}
},
"Message": {
"type": "object",
"properties": {
"attachment": {
"$ref": "#/definitions/Attachment"
},
"createdAt": {
"type": "string"
},
"id": {
"type": "string"
},
"text": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"user": {
"$ref": "#/definitions/Member"
}
}
},
"MessageRequest": {
"type": "object",
"properties": {
"file": {
"description": "image/* or audio/*",
"type": "string",
"format": "binary"
},
"text": {
"description": "Maximum 2000 characters",
"type": "string"
}
}
},
"RegisterRequest": {
"type": "object",
"properties": {
"email": {
"description": "Must be unique",
"type": "string"
},
"password": {
"description": "Min 6, max 150 characters.",
"type": "string"
},
"username": {
"description": "Min 3, max 30 characters.",
"type": "string"
}
}
},
"ResetPasswordRequest": {
"type": "object",
"properties": {
"confirmNewPassword": {
"description": "Must be the same as the password value.",
"type": "string"
},
"newPassword": {
"description": "Min 6, max 150 characters.",
"type": "string"
},
"token": {
"description": "The token the user got from the email.",
"type": "string"
}
}
},
"SuccessResponse": {
"type": "object",
"properties": {
"success": {
"description": "Only returns true, not a json object",
"type": "boolean"
}
}
},
"User": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"email": {
"type": "string"
},
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"isOnline": {
"type": "boolean"
},
"updatedAt": {
"type": "string"
},
"username": {
"type": "string"
}
}
}
}
}`
type swaggerInfo struct {
Version string
Host string
BasePath string
Schemes []string
Title string
Description string
}
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = swaggerInfo{
Version: "1.0",
Host: "localhost:",
BasePath: "/api",
Schemes: []string{},
Title: "Valkyrie API",
Description: "Valkyrie REST API Specs. This service uses sessions for authentication",
}
type s struct{}
func (s *s) ReadDoc() string {
sInfo := SwaggerInfo
sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1)
t, err := template.New("swagger_info").Funcs(template.FuncMap{
"marshal": func(v any) string {
a, _ := json.Marshal(v)
return string(a)
},
}).Parse(doc)
if err != nil {
return doc
}
var tpl bytes.Buffer
if err := t.Execute(&tpl, sInfo); err != nil {
return doc
}
return tpl.String()
}
func init() {
swag.Register(swag.Name, &s{})
}
================================================
FILE: server/docs/swagger.json
================================================
{
"swagger": "2.0",
"info": {
"description": "Valkyrie REST API Specs. This service uses sessions for authentication",
"title": "Valkyrie API",
"contact": {},
"license": {
"name": "Apache 2.0"
},
"version": "1.0"
},
"host": "localhost:\u003cPORT\u003e",
"basePath": "/api",
"paths": {
"/account": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Get Current User",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/User"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"put": {
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Update Current User",
"parameters": [
{
"description": "Update Account",
"name": "account",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/EditUser"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/change-password": {
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Change Current User's Password",
"parameters": [
{
"description": "Change Password",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/forgot-password": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Forgot Password Request",
"parameters": [
{
"description": "Forgot Password",
"name": "email",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ForgotPasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/login": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "User Login",
"parameters": [
{
"description": "Login account",
"name": "account",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/LoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/logout": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "User Logout",
"parameters": [
{
"description": "Login account",
"name": "account",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/LoginRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
}
}
}
},
"/account/me/friends": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Get Current User's Friends",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Friend"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/me/pending": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Get Current User's Friend Requests",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/FriendRequest"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/register": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Create an Account",
"parameters": [
{
"description": "Create account",
"name": "account",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/RegisterRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/reset-password": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Account"
],
"summary": "Reset Password",
"parameters": [
{
"description": "Reset Password",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ResetPasswordRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/User"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/{memberId}/friend": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Send Friend Request",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "memberId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Remove Friend",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "memberId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/{memberId}/friend/accept": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Accept Friend's Request",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "memberId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/account/{memberId}/friend/cancel": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Friends"
],
"summary": "Cancel Friend's Request",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "memberId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/me/dm": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Get User's DMs",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/DirectMessage"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{channelId}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Edit Channel",
"parameters": [
{
"type": "string",
"description": "Channel ID",
"name": "channelId",
"in": "path",
"required": true
},
{
"description": "Edit Channel",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/ChannelRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{channelId}/dm": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Get or Create DM",
"parameters": [
{
"type": "string",
"description": "Member ID",
"name": "channelId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/DirectMessage"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{channelId}/members": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Get Members of the given Channel",
"parameters": [
{
"type": "string",
"description": "Channel ID",
"name": "channelId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{guildId}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Get Guild Channels",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Channel"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"post": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Create Channel",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Channel"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{id}": {
"delete": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Delete Channel",
"parameters": [
{
"type": "string",
"description": "Channel ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/channels/{id}/dm": {
"delete": {
"produces": [
"application/json"
],
"tags": [
"Channels"
],
"summary": "Close DM",
"parameters": [
{
"type": "string",
"description": "DM Channel ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Get Current User's Guilds",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/GuildResponse"
}
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/create": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Create Guild",
"parameters": [
{
"description": "Create Guild",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/CreateGuildRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/GuildResponse"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/join": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Join Guild",
"parameters": [
{
"description": "Join Guild",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/JoinRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/GuildResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Edit Guild",
"parameters": [
{
"description": "Edit Guild",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/EditGuildRequest"
}
},
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Leave Guild",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/bans": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Get Guild Ban list",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/BanResponse"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"post": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Ban Member",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
},
{
"description": "Member ID",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MemberRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/SuccessResponse"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Unban Member",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
},
{
"description": "Member ID",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MemberRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/SuccessResponse"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/delete": {
"delete": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Delete Guild",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/invite": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Get Guild Invite",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Is Permanent",
"name": "isPermanent",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Delete all permanent invite links",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/kick": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Kick Member",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
},
{
"description": "Member ID",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MemberRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/SuccessResponse"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/member": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Get Member Settings",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/MemberSettings"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"put": {
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Edit Member Settings",
"parameters": [
{
"description": "Edit Member",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MemberSettingsRequest"
}
},
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/guilds/{guildId}/members": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Guilds"
],
"summary": "Get Guild Members",
"parameters": [
{
"type": "string",
"description": "Guild ID",
"name": "guildId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Member"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/messages/{channelId}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Messages"
],
"summary": "Get Channel Messages",
"parameters": [
{
"type": "string",
"description": "Channel ID",
"name": "channelId",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Cursor Pagination using the createdAt field",
"name": "cursor",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Message"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"post": {
"produces": [
"application/json"
],
"tags": [
"Messages"
],
"summary": "Create Messages",
"parameters": [
{
"type": "string",
"description": "Channel ID",
"name": "channelId",
"in": "path",
"required": true
},
{
"description": "Create Message",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MessageRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
},
"/messages/{messageId}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"Messages"
],
"summary": "Edit Messages",
"parameters": [
{
"type": "string",
"description": "Message ID",
"name": "messageId",
"in": "path",
"required": true
},
{
"description": "Edit Message",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/MessageRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/ErrorsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Messages"
],
"summary": "Delete Messages",
"parameters": [
{
"type": "string",
"description": "Message ID",
"name": "messageId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SuccessResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
}
}
}
},
"definitions": {
"Attachment": {
"type": "object",
"properties": {
"filename": {
"type": "string"
},
"filetype": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"BanResponse": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"ChangePasswordRequest": {
"type": "object",
"properties": {
"confirmNewPassword": {
"description": "Must be the same as the newPassword value.",
"type": "string"
},
"currentPassword": {
"type": "string"
},
"newPassword": {
"description": "Min 6, max 150 characters.",
"type": "string"
}
}
},
"Channel": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"hasNotification": {
"type": "boolean"
},
"id": {
"type": "string"
},
"isPublic": {
"type": "boolean"
},
"name": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"ChannelRequest": {
"type": "object",
"properties": {
"isPublic": {
"description": "Default is true",
"type": "boolean"
},
"members": {
"description": "Array of memberIds",
"type": "array",
"items": {
"type": "string"
}
},
"name": {
"description": "Channel Name. 3 to 30 character",
"type": "string"
}
}
},
"CreateGuildRequest": {
"type": "object",
"properties": {
"name": {
"description": "Guild Name. 3 to 30 characters",
"type": "string"
}
}
},
"DMUser": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"isFriend": {
"type": "boolean"
},
"isOnline": {
"type": "boolean"
},
"username": {
"type": "string"
}
}
},
"DirectMessage": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"user": {
"$ref": "#/definitions/DMUser"
}
}
},
"EditGuildRequest": {
"type": "object",
"properties": {
"icon": {
"description": "The old guild icon url if no new image is selected. Set to null to reset the guild icon",
"type": "string"
},
"image": {
"description": "image/png or image/jpeg",
"type": "string",
"format": "binary"
},
"name": {
"description": "Guild Name. 3 to 30 characters",
"type": "string"
}
}
},
"EditUser": {
"type": "object",
"properties": {
"email": {
"description": "Must be unique",
"type": "string"
},
"image": {
"description": "image/png or image/jpeg",
"type": "string",
"format": "binary"
},
"username": {
"description": "Min 3, max 30 characters.",
"type": "string"
}
}
},
"ErrorResponse": {
"type": "object",
"properties": {
"error": {
"$ref": "#/definitions/HttpError"
}
}
},
"ErrorsResponse": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"$ref": "#/definitions/FieldError"
}
}
}
},
"FieldError": {
"type": "object",
"properties": {
"field": {
"description": "The property containing the error",
"type": "string"
},
"message": {
"description": "The specific error message",
"type": "string"
}
}
},
"ForgotPasswordRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
}
}
},
"Friend": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"isOnline": {
"type": "boolean"
},
"username": {
"type": "string"
}
}
},
"FriendRequest": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"type": {
"description": "1: Incoming, 0: Outgoing",
"type": "integer",
"enum": [
0,
1
]
},
"username": {
"type": "string"
}
}
},
"GuildResponse": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"default_channel_id": {
"type": "string"
},
"hasNotification": {
"type": "boolean"
},
"icon": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"ownerId": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
}
},
"HttpError": {
"type": "object",
"properties": {
"message": {
"description": "The specific error message",
"type": "string"
},
"type": {
"description": "The Http Response as a string",
"type": "string"
}
}
},
"JoinRequest": {
"type": "object",
"properties": {
"link": {
"type": "string"
}
}
},
"LoginRequest": {
"type": "object",
"properties": {
"email": {
"description": "Must be unique",
"type": "string"
},
"password": {
"description": "Min 6, max 150 characters.",
"type": "string"
}
}
},
"Member": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"isFriend": {
"type": "boolean"
},
"isOnline": {
"type": "boolean"
},
"nickname": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
"MemberRequest": {
"type": "object",
"properties": {
"memberId": {
"type": "string"
}
}
},
"MemberSettings": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"nickname": {
"type": "string"
}
}
},
"MemberSettingsRequest": {
"type": "object",
"properties": {
"color": {
"type": "string"
},
"nickname": {
"type": "string"
}
}
},
"Message": {
"type": "object",
"properties": {
"attachment": {
"$ref": "#/definitions/Attachment"
},
"createdAt": {
"type": "string"
},
"id": {
"type": "string"
},
"text": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"user": {
"$ref": "#/definitions/Member"
}
}
},
"MessageRequest": {
"type": "object",
"properties": {
"file": {
"description": "image/* or audio/*",
"type": "string",
"format": "binary"
},
"text": {
"description": "Maximum 2000 characters",
"type": "string"
}
}
},
"RegisterRequest": {
"type": "object",
"properties": {
"email": {
"description": "Must be unique",
"type": "string"
},
"password": {
"description": "Min 6, max 150 characters.",
"type": "string"
},
"username": {
"description": "Min 3, max 30 characters.",
"type": "string"
}
}
},
"ResetPasswordRequest": {
"type": "object",
"properties": {
"confirmNewPassword": {
"description": "Must be the same as the password value.",
"type": "string"
},
"newPassword": {
"description": "Min 6, max 150 characters.",
"type": "string"
},
"token": {
"description": "The token the user got from the email.",
"type": "string"
}
}
},
"SuccessResponse": {
"type": "object",
"properties": {
"success": {
"description": "Only returns true, not a json object",
"type": "boolean"
}
}
},
"User": {
"type": "object",
"properties": {
"createdAt": {
"type": "string"
},
"email": {
"type": "string"
},
"id": {
"type": "string"
},
"image": {
"type": "string"
},
"isOnline": {
"type": "boolean"
},
"updatedAt": {
"type": "string"
},
"username": {
"type": "string"
}
}
}
}
}
================================================
FILE: server/docs/swagger.yaml
================================================
basePath: /api
definitions:
Attachment:
properties:
filename:
type: string
filetype:
type: string
url:
type: string
type: object
BanResponse:
properties:
id:
type: string
image:
type: string
username:
type: string
type: object
ChangePasswordRequest:
properties:
confirmNewPassword:
description: Must be the same as the newPassword value.
type: string
currentPassword:
type: string
newPassword:
description: Min 6, max 150 characters.
type: string
type: object
Channel:
properties:
createdAt:
type: string
hasNotification:
type: boolean
id:
type: string
isPublic:
type: boolean
name:
type: string
updatedAt:
type: string
type: object
ChannelRequest:
properties:
isPublic:
description: Default is true
type: boolean
members:
description: Array of memberIds
items:
type: string
type: array
name:
description: Channel Name. 3 to 30 character
type: string
type: object
CreateGuildRequest:
properties:
name:
description: Guild Name. 3 to 30 characters
type: string
type: object
DMUser:
properties:
id:
type: string
image:
type: string
isFriend:
type: boolean
isOnline:
type: boolean
username:
type: string
type: object
DirectMessage:
properties:
id:
type: string
user:
$ref: '#/definitions/DMUser'
type: object
EditGuildRequest:
properties:
icon:
description: The old guild icon url if no new image is selected. Set to null
to reset the guild icon
type: string
image:
description: image/png or image/jpeg
format: binary
type: string
name:
description: Guild Name. 3 to 30 characters
type: string
type: object
EditUser:
properties:
email:
description: Must be unique
type: string
image:
description: image/png or image/jpeg
format: binary
type: string
username:
description: Min 3, max 30 characters.
type: string
type: object
ErrorResponse:
properties:
error:
$ref: '#/definitions/HttpError'
type: object
ErrorsResponse:
properties:
errors:
items:
$ref: '#/definitions/FieldError'
type: array
type: object
FieldError:
properties:
field:
description: The property containing the error
type: string
message:
description: The specific error message
type: string
type: object
ForgotPasswordRequest:
properties:
email:
type: string
type: object
Friend:
properties:
id:
type: string
image:
type: string
isOnline:
type: boolean
username:
type: string
type: object
FriendRequest:
properties:
id:
type: string
image:
type: string
type:
description: '1: Incoming, 0: Outgoing'
enum:
- 0
- 1
type: integer
username:
type: string
type: object
GuildResponse:
properties:
createdAt:
type: string
default_channel_id:
type: string
hasNotification:
type: boolean
icon:
type: string
id:
type: string
name:
type: string
ownerId:
type: string
updatedAt:
type: string
type: object
HttpError:
properties:
message:
description: The specific error message
type: string
type:
description: The Http Response as a string
type: string
type: object
JoinRequest:
properties:
link:
type: string
type: object
LoginRequest:
properties:
email:
description: Must be unique
type: string
password:
description: Min 6, max 150 characters.
type: string
type: object
Member:
properties:
color:
type: string
createdAt:
type: string
id:
type: string
image:
type: string
isFriend:
type: boolean
isOnline:
type: boolean
nickname:
type: string
updatedAt:
type: string
username:
type: string
type: object
MemberRequest:
properties:
memberId:
type: string
type: object
MemberSettings:
properties:
color:
type: string
nickname:
type: string
type: object
MemberSettingsRequest:
properties:
color:
type: string
nickname:
type: string
type: object
Message:
properties:
attachment:
$ref: '#/definitions/Attachment'
createdAt:
type: string
id:
type: string
text:
type: string
updatedAt:
type: string
user:
$ref: '#/definitions/Member'
type: object
MessageRequest:
properties:
file:
description: image/* or audio/*
format: binary
type: string
text:
description: Maximum 2000 characters
type: string
type: object
RegisterRequest:
properties:
email:
description: Must be unique
type: string
password:
description: Min 6, max 150 characters.
type: string
username:
description: Min 3, max 30 characters.
type: string
type: object
ResetPasswordRequest:
properties:
confirmNewPassword:
description: Must be the same as the password value.
type: string
newPassword:
description: Min 6, max 150 characters.
type: string
token:
description: The token the user got from the email.
type: string
type: object
SuccessResponse:
properties:
success:
description: Only returns true, not a json object
type: boolean
type: object
User:
properties:
createdAt:
type: string
email:
type: string
id:
type: string
image:
type: string
isOnline:
type: boolean
updatedAt:
type: string
username:
type: string
type: object
host: localhost:
info:
contact: {}
description: Valkyrie REST API Specs. This service uses sessions for authentication
license:
name: Apache 2.0
title: Valkyrie API
version: "1.0"
paths:
/account:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/User'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Current User
tags:
- Account
put:
consumes:
- multipart/form-data
parameters:
- description: Update Account
in: body
name: account
required: true
schema:
$ref: '#/definitions/EditUser'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/User'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Update Current User
tags:
- Account
/account/{memberId}/friend:
delete:
parameters:
- description: User ID
in: path
name: memberId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Remove Friend
tags:
- Friends
post:
parameters:
- description: User ID
in: path
name: memberId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Send Friend Request
tags:
- Friends
/account/{memberId}/friend/accept:
post:
parameters:
- description: User ID
in: path
name: memberId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Accept Friend's Request
tags:
- Friends
/account/{memberId}/friend/cancel:
post:
parameters:
- description: User ID
in: path
name: memberId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Cancel Friend's Request
tags:
- Friends
/account/change-password:
put:
consumes:
- application/json
parameters:
- description: Change Password
in: body
name: request
required: true
schema:
$ref: '#/definitions/ChangePasswordRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Change Current User's Password
tags:
- Account
/account/forgot-password:
post:
consumes:
- application/json
parameters:
- description: Forgot Password
in: body
name: email
required: true
schema:
$ref: '#/definitions/ForgotPasswordRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Forgot Password Request
tags:
- Account
/account/login:
post:
consumes:
- application/json
parameters:
- description: Login account
in: body
name: account
required: true
schema:
$ref: '#/definitions/LoginRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/User'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: User Login
tags:
- Account
/account/logout:
post:
consumes:
- application/json
parameters:
- description: Login account
in: body
name: account
required: true
schema:
$ref: '#/definitions/LoginRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
summary: User Logout
tags:
- Account
/account/me/friends:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/Friend'
type: array
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Current User's Friends
tags:
- Friends
/account/me/pending:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/FriendRequest'
type: array
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Current User's Friend Requests
tags:
- Friends
/account/register:
post:
consumes:
- application/json
parameters:
- description: Create account
in: body
name: account
required: true
schema:
$ref: '#/definitions/RegisterRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/User'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Create an Account
tags:
- Account
/account/reset-password:
post:
consumes:
- application/json
parameters:
- description: Reset Password
in: body
name: request
required: true
schema:
$ref: '#/definitions/ResetPasswordRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/User'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Reset Password
tags:
- Account
/channels/{channelId}:
put:
parameters:
- description: Channel ID
in: path
name: channelId
required: true
type: string
- description: Edit Channel
in: body
name: request
required: true
schema:
$ref: '#/definitions/ChannelRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Edit Channel
tags:
- Channels
/channels/{channelId}/dm:
post:
parameters:
- description: Member ID
in: path
name: channelId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/DirectMessage'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get or Create DM
tags:
- Channels
/channels/{channelId}/members:
get:
parameters:
- description: Channel ID
in: path
name: channelId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
type: string
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Members of the given Channel
tags:
- Channels
/channels/{guildId}:
get:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/Channel'
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Guild Channels
tags:
- Channels
post:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/Channel'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Create Channel
tags:
- Channels
/channels/{id}:
delete:
parameters:
- description: Channel ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Delete Channel
tags:
- Channels
/channels/{id}/dm:
delete:
parameters:
- description: DM Channel ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Close DM
tags:
- Channels
/channels/me/dm:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/DirectMessage'
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get User's DMs
tags:
- Channels
/guilds:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/GuildResponse'
type: array
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Current User's Guilds
tags:
- Guilds
/guilds/{guildId}:
delete:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Leave Guild
tags:
- Guilds
put:
parameters:
- description: Edit Guild
in: body
name: request
required: true
schema:
$ref: '#/definitions/EditGuildRequest'
- description: Guild ID
in: path
name: guildId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Edit Guild
tags:
- Guilds
/guilds/{guildId}/bans:
delete:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
- description: Member ID
in: body
name: request
required: true
schema:
$ref: '#/definitions/MemberRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/SuccessResponse'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Unban Member
tags:
- Members
get:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/BanResponse'
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Guild Ban list
tags:
- Members
post:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
- description: Member ID
in: body
name: request
required: true
schema:
$ref: '#/definitions/MemberRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/SuccessResponse'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Ban Member
tags:
- Members
/guilds/{guildId}/delete:
delete:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Delete Guild
tags:
- Guilds
/guilds/{guildId}/invite:
delete:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Delete all permanent invite links
tags:
- Guilds
get:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
- description: Is Permanent
in: query
name: isPermanent
type: boolean
produces:
- application/json
responses:
"200":
description: OK
schema:
type: string
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Guild Invite
tags:
- Guilds
/guilds/{guildId}/kick:
post:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
- description: Member ID
in: body
name: request
required: true
schema:
$ref: '#/definitions/MemberRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/SuccessResponse'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Kick Member
tags:
- Members
/guilds/{guildId}/member:
get:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/MemberSettings'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Member Settings
tags:
- Members
put:
parameters:
- description: Edit Member
in: body
name: request
required: true
schema:
$ref: '#/definitions/MemberSettingsRequest'
- description: Guild ID
in: path
name: guildId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Edit Member Settings
tags:
- Members
/guilds/{guildId}/members:
get:
parameters:
- description: Guild ID
in: path
name: guildId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/Member'
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Guild Members
tags:
- Guilds
/guilds/create:
post:
parameters:
- description: Create Guild
in: body
name: request
required: true
schema:
$ref: '#/definitions/CreateGuildRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
items:
$ref: '#/definitions/GuildResponse'
type: array
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Create Guild
tags:
- Guilds
/guilds/join:
post:
parameters:
- description: Join Guild
in: body
name: request
required: true
schema:
$ref: '#/definitions/JoinRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/GuildResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Join Guild
tags:
- Guilds
/messages/{channelId}:
get:
parameters:
- description: Channel ID
in: path
name: channelId
required: true
type: string
- description: Cursor Pagination using the createdAt field
in: query
name: cursor
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/Message'
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get Channel Messages
tags:
- Messages
post:
parameters:
- description: Channel ID
in: path
name: channelId
required: true
type: string
- description: Create Message
in: body
name: request
required: true
schema:
$ref: '#/definitions/MessageRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Create Messages
tags:
- Messages
/messages/{messageId}:
delete:
parameters:
- description: Message ID
in: path
name: messageId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Delete Messages
tags:
- Messages
put:
parameters:
- description: Message ID
in: path
name: messageId
required: true
type: string
- description: Edit Message
in: body
name: request
required: true
schema:
$ref: '#/definitions/MessageRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/SuccessResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/ErrorsResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Edit Messages
tags:
- Messages
swagger: "2.0"
================================================
FILE: server/e2e_test.go
================================================
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/sentrionic/valkyrie/config"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/stretchr/testify/assert"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func setupTest(t *testing.T) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
ctx := context.Background()
// Load config for local testing, ignore for CI
_ = godotenv.Load()
cfg, err := config.LoadConfig(ctx)
assert.NoError(t, err)
ds, err := initDS(ctx, cfg)
assert.NoError(t, err)
router, err := inject(ds, cfg)
assert.NoError(t, err)
return router
}
func TestMain_AccountE2E(t *testing.T) {
router := setupTest(t)
authUser := fixture.GetMockUser()
cookie := ""
mockUsername := fixture.Username()
newPassword := fixture.RandStr(10)
testCases := []struct {
name string
setupRequest func() (*http.Request, error)
setupHeaders func(t *testing.T, request *http.Request)
checkResponse func(recorder *httptest.ResponseRecorder)
}{
{
name: "Register Account",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": authUser.Username,
"password": authUser.Password,
"email": authUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, authUser.Username, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.Equal(t, authUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.Contains(t, recorder.Header(), "Set-Cookie")
authUser.ID = respBody.ID
},
},
{
name: "Login Account",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"password": authUser.Password,
"email": authUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/login", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, authUser.Username, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.Equal(t, authUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.Equal(t, authUser.ID, respBody.ID)
assert.NotNil(t, respBody.Image)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.Contains(t, recorder.Header(), "Set-Cookie")
cookie = recorder.Header().Get("Set-Cookie")
},
},
{
name: "Get Account",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/account", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Add("Cookie", cookie)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, authUser.Username, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.Equal(t, authUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.Equal(t, authUser.ID, respBody.ID)
assert.NotNil(t, respBody.Image)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
},
},
{
name: "Edit Account",
setupRequest: func() (*http.Request, error) {
form := url.Values{}
form.Add("username", mockUsername)
form.Add("email", authUser.Email)
request, err := http.NewRequest(http.MethodPut, "/api/account", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
request.Form = form
return request, nil
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockUsername, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.Equal(t, authUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.Equal(t, authUser.ID, respBody.ID)
assert.NotNil(t, respBody.Image)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
},
},
{
name: "Change Password",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"currentPassword": authUser.Password,
"newPassword": newPassword,
"confirmNewPassword": newPassword,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPut, "/api/account/change-password", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Add("Cookie", cookie)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Login to verify changes",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"password": newPassword,
"email": authUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/login", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockUsername, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.Equal(t, authUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.Equal(t, authUser.ID, respBody.ID)
assert.NotNil(t, respBody.Image)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
},
},
{
name: "Logout",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodPost, "/api/account/logout", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Add("Cookie", cookie)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
},
},
{
name: "Check session is invalid now",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/account", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Add("Cookie", cookie)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusUnauthorized, recorder.Code)
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
request, err := tc.setupRequest()
tc.setupHeaders(t, request)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
tc.checkResponse(rr)
})
}
}
func TestMain_FriendsE2E(t *testing.T) {
router := setupTest(t)
authUser := fixture.GetMockUser()
cookie := ""
mockUser := fixture.GetMockUser()
mockUserCookie := ""
testCases := []struct {
name string
setupRequest func() (*http.Request, error)
setupHeaders func(t *testing.T, request *http.Request)
checkResponse func(recorder *httptest.ResponseRecorder)
}{
{
name: "Register Account",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": authUser.Username,
"password": authUser.Password,
"email": authUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, authUser.Username, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.True(t, respBody.IsOnline)
assert.Equal(t, authUser.Image, respBody.Image)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.Contains(t, recorder.Header(), "Set-Cookie")
authUser.ID = respBody.ID
cookie = recorder.Header().Get("Set-Cookie")
},
},
{
name: "Register Friend",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": mockUser.Username,
"password": mockUser.Password,
"email": mockUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockUser.Username, respBody.Username)
assert.Equal(t, mockUser.Email, respBody.Email)
assert.True(t, respBody.IsOnline)
assert.Equal(t, mockUser.Image, respBody.Image)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
mockUser.ID = respBody.ID
mockUserCookie = recorder.Header().Get("Set-Cookie")
},
},
{
name: "Send friend request",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
return http.NewRequest(http.MethodPost, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Get friend requests",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/account/me/pending", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.FriendRequest{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
requests := *respBody
assert.Equal(t, 1, len(requests))
request := requests[0]
assert.Equal(t, authUser.Username, request.Username)
assert.Equal(t, authUser.ID, request.Id)
assert.Equal(t, authUser.Image, request.Image)
assert.Equal(t, model.Incoming, request.Type)
},
},
{
name: "Accept friend request",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/account/%s/friend/accept", authUser.ID)
return http.NewRequest(http.MethodPost, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Check that requests are empty now",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/account/me/pending", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.FriendRequest{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
requests := *respBody
assert.Equal(t, 0, len(requests))
},
},
{
name: "Get friends",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/account/me/friends", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.Friend{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
friends := *respBody
assert.Equal(t, 1, len(friends))
friend := friends[0]
assert.Equal(t, authUser.Username, friend.Username)
assert.Equal(t, authUser.ID, friend.Id)
assert.Equal(t, authUser.Image, friend.Image)
assert.True(t, friend.IsOnline)
},
},
{
name: "Remove friend",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/account/%s/friend", authUser.ID)
return http.NewRequest(http.MethodDelete, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Confirm friends list is empty",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/account/me/friends", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.Friend{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
friends := *respBody
assert.Equal(t, 0, len(friends))
},
},
{
name: "Send another friend request",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
return http.NewRequest(http.MethodPost, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Get authUser's friend requests",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/account/me/pending", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.FriendRequest{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
requests := *respBody
assert.Equal(t, 1, len(requests))
request := requests[0]
assert.Equal(t, mockUser.Username, request.Username)
assert.Equal(t, mockUser.ID, request.Id)
assert.Equal(t, mockUser.Image, request.Image)
assert.Equal(t, model.Outgoing, request.Type)
},
},
{
name: "Cancel friend request",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/account/%s/friend/accept", mockUser.ID)
return http.NewRequest(http.MethodPost, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
request, err := tc.setupRequest()
tc.setupHeaders(t, request)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
tc.checkResponse(rr)
})
}
}
func TestMain_GuildsE2E(t *testing.T) {
router := setupTest(t)
authUser := fixture.GetMockUser()
cookie := ""
mockUser := fixture.GetMockUser()
mockUserCookie := ""
mockGuild := fixture.GetMockGuild("")
inviteLink := ""
mockName := fixture.Username()
testCases := []struct {
name string
setupRequest func() (*http.Request, error)
setupHeaders func(t *testing.T, request *http.Request)
checkResponse func(recorder *httptest.ResponseRecorder)
}{
{
name: "Register Account",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": authUser.Username,
"password": authUser.Password,
"email": authUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, authUser.Username, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.True(t, respBody.IsOnline)
assert.Equal(t, authUser.Image, respBody.Image)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.Contains(t, recorder.Header(), "Set-Cookie")
authUser.ID = respBody.ID
cookie = recorder.Header().Get("Set-Cookie")
},
},
{
name: "Register Member",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": mockUser.Username,
"password": mockUser.Password,
"email": mockUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockUser.Username, respBody.Username)
assert.Equal(t, mockUser.Email, respBody.Email)
assert.True(t, respBody.IsOnline)
assert.Equal(t, mockUser.Image, respBody.Image)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
mockUser.ID = respBody.ID
mockUserCookie = recorder.Header().Get("Set-Cookie")
},
},
{
name: "Create Guild",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"name": mockGuild.Name,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/guilds/create", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockGuild.Name, respBody.Name)
assert.Equal(t, authUser.ID, respBody.OwnerId)
assert.NotNil(t, respBody.Id)
assert.Nil(t, respBody.Icon)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.False(t, respBody.HasNotification)
assert.NotNil(t, respBody.DefaultChannelId)
mockGuild.ID = respBody.Id
mockGuild.OwnerId = authUser.ID
},
},
{
name: "Get authUser's guilds",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/guilds", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
guilds := *respBody
assert.Equal(t, 1, len(guilds))
guild := guilds[0]
assert.Equal(t, mockGuild.Name, guild.Name)
assert.Equal(t, mockGuild.ID, guild.Id)
assert.Equal(t, mockGuild.OwnerId, guild.OwnerId)
assert.Nil(t, guild.Icon)
assert.NotNil(t, guild.CreatedAt)
assert.NotNil(t, guild.UpdatedAt)
assert.False(t, guild.HasNotification)
assert.NotNil(t, guild.DefaultChannelId)
},
},
{
name: "Edit Guild",
setupRequest: func() (*http.Request, error) {
form := url.Values{}
form.Add("name", mockName)
request, err := http.NewRequest(http.MethodPut, "/api/guilds/"+mockGuild.ID, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
request.Form = form
return request, nil
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.NotNil(t, respBody)
},
},
{
name: "Get guild invite",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", mockGuild.ID)
return http.NewRequest(http.MethodGet, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
err := json.Unmarshal(recorder.Body.Bytes(), &inviteLink)
assert.NoError(t, err)
assert.NotNil(t, inviteLink)
},
},
{
name: "Join Guild",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"link": inviteLink,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockName, respBody.Name)
assert.Equal(t, authUser.ID, respBody.OwnerId)
assert.NotNil(t, respBody.Id)
assert.Nil(t, respBody.Icon)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.False(t, respBody.HasNotification)
assert.NotNil(t, respBody.DefaultChannelId)
},
},
{
name: "Check mockUser is in the guild",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/guilds", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
guilds := *respBody
assert.Equal(t, 1, len(guilds))
guild := guilds[0]
assert.Equal(t, mockName, guild.Name)
assert.Equal(t, mockGuild.ID, guild.Id)
assert.Equal(t, mockGuild.OwnerId, guild.OwnerId)
assert.Nil(t, guild.Icon)
assert.NotNil(t, guild.CreatedAt)
assert.NotNil(t, guild.UpdatedAt)
assert.False(t, guild.HasNotification)
assert.NotNil(t, guild.DefaultChannelId)
},
},
{
name: "Leave Guild",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodDelete, "/api/guilds/"+mockGuild.ID, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Check mockUser is in no guilds anymore",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/guilds", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
guilds := *respBody
assert.Equal(t, 0, len(guilds))
},
},
{
name: "Get guild members",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/guilds/%s/members", mockGuild.ID)
return http.NewRequest(http.MethodGet, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.MemberResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
members := *respBody
assert.Equal(t, 1, len(members))
member := members[0]
assert.Equal(t, authUser.Username, member.Username)
assert.Equal(t, authUser.ID, member.Id)
assert.True(t, member.IsOnline)
assert.NotNil(t, member.Image)
assert.NotNil(t, member.CreatedAt)
assert.NotNil(t, member.UpdatedAt)
assert.Nil(t, member.Nickname)
assert.Nil(t, member.Color)
},
},
{
name: "Delete Guild",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/guilds/%s/delete", mockGuild.ID)
return http.NewRequest(http.MethodDelete, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Verify that there are no more guilds",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/guilds", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
guilds := *respBody
assert.Len(t, guilds, 0)
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
request, err := tc.setupRequest()
tc.setupHeaders(t, request)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
tc.checkResponse(rr)
})
}
}
func TestMain_MembersE2E(t *testing.T) {
router := setupTest(t)
authUser := fixture.GetMockUser()
cookie := ""
mockUser := fixture.GetMockUser()
mockUserCookie := ""
mockGuild := fixture.GetMockGuild("")
inviteLink := ""
testCases := []struct {
name string
setupRequest func() (*http.Request, error)
setupHeaders func(t *testing.T, request *http.Request)
checkResponse func(recorder *httptest.ResponseRecorder)
}{
{
name: "Register Account",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": authUser.Username,
"password": authUser.Password,
"email": authUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, authUser.Username, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.Equal(t, authUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.Contains(t, recorder.Header(), "Set-Cookie")
authUser.ID = respBody.ID
cookie = recorder.Header().Get("Set-Cookie")
},
},
{
name: "Register Member",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": mockUser.Username,
"password": mockUser.Password,
"email": mockUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockUser.Username, respBody.Username)
assert.Equal(t, mockUser.Email, respBody.Email)
assert.Equal(t, mockUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.Image)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
mockUser.ID = respBody.ID
mockUserCookie = recorder.Header().Get("Set-Cookie")
},
},
{
name: "Create Guild",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"name": mockGuild.Name,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/guilds/create", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockGuild.Name, respBody.Name)
assert.Equal(t, authUser.ID, respBody.OwnerId)
assert.NotNil(t, respBody.Id)
assert.Nil(t, respBody.Icon)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.False(t, respBody.HasNotification)
assert.NotNil(t, respBody.DefaultChannelId)
mockGuild.ID = respBody.Id
mockGuild.OwnerId = authUser.ID
},
},
{
name: "Edit authUser's member settings",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"nickname": authUser.Username,
"color": "#fff",
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/member", mockGuild.ID)
return http.NewRequest(http.MethodPut, reqUrl, bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.NotNil(t, respBody)
},
},
{
name: "Get authUser's member settings",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/guilds/%s/member", mockGuild.ID)
return http.NewRequest(http.MethodGet, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &model.MemberSettings{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, authUser.Username, *respBody.Nickname)
assert.Equal(t, "#fff", *respBody.Color)
},
},
{
name: "Get guild invite",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/guilds/%s/invite?isPermanent=true", mockGuild.ID)
return http.NewRequest(http.MethodGet, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
err := json.Unmarshal(recorder.Body.Bytes(), &inviteLink)
assert.NoError(t, err)
assert.NotNil(t, inviteLink)
},
},
{
name: "Join Guild",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"link": inviteLink,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockGuild.Name, respBody.Name)
assert.Equal(t, authUser.ID, respBody.OwnerId)
assert.NotNil(t, respBody.Id)
assert.Nil(t, respBody.Icon)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.False(t, respBody.HasNotification)
assert.NotNil(t, respBody.DefaultChannelId)
},
},
{
name: "Check mockUser is in the guild",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/guilds", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
guilds := *respBody
assert.Equal(t, 1, len(guilds))
guild := guilds[0]
assert.Equal(t, mockGuild.Name, guild.Name)
assert.Equal(t, mockGuild.ID, guild.Id)
assert.Equal(t, mockGuild.OwnerId, guild.OwnerId)
assert.Nil(t, guild.Icon)
assert.NotNil(t, guild.CreatedAt)
assert.NotNil(t, guild.UpdatedAt)
assert.False(t, guild.HasNotification)
assert.NotNil(t, guild.DefaultChannelId)
},
},
{
name: "Kick member",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"memberId": mockUser.ID,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/kick", mockGuild.ID)
return http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.NotNil(t, respBody)
},
},
{
name: "Check mockUser is in no guilds anymore",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/guilds", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
guilds := *respBody
assert.Equal(t, 0, len(guilds))
},
},
{
name: "Rejoin Guild",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"link": inviteLink,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockGuild.Name, respBody.Name)
assert.Equal(t, authUser.ID, respBody.OwnerId)
assert.NotNil(t, respBody.Id)
assert.Nil(t, respBody.Icon)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.False(t, respBody.HasNotification)
assert.NotNil(t, respBody.DefaultChannelId)
},
},
{
name: "Check mockUser is in the guild",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/guilds", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
guilds := *respBody
assert.Equal(t, 1, len(guilds))
guild := guilds[0]
assert.Equal(t, mockGuild.Name, guild.Name)
assert.Equal(t, mockGuild.ID, guild.Id)
assert.Equal(t, mockGuild.OwnerId, guild.OwnerId)
assert.Nil(t, guild.Icon)
assert.NotNil(t, guild.CreatedAt)
assert.NotNil(t, guild.UpdatedAt)
assert.False(t, guild.HasNotification)
assert.NotNil(t, guild.DefaultChannelId)
},
},
{
name: "Ban member",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"memberId": mockUser.ID,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
return http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.NotNil(t, respBody)
},
},
{
name: "Check mockUser is in no guilds anymore",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/guilds", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", mockUserCookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
guilds := *respBody
assert.Equal(t, 0, len(guilds))
},
},
{
name: "Get guild ban list",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
return http.NewRequest(http.MethodGet, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.BanResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
bans := *respBody
assert.Equal(t, 1, len(bans))
ban := bans[0]
assert.Equal(t, mockUser.Username, ban.Username)
assert.Equal(t, mockUser.ID, ban.Id)
assert.NotNil(t, ban.Image)
},
},
{
name: "Unban member",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"memberId": mockUser.ID,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
return http.NewRequest(http.MethodDelete, reqUrl, bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.NotNil(t, respBody)
},
},
{
name: "Verify ban list is empty",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
return http.NewRequest(http.MethodGet, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.BanResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
bans := *respBody
assert.Equal(t, 0, len(bans))
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
request, err := tc.setupRequest()
tc.setupHeaders(t, request)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
tc.checkResponse(rr)
})
}
}
func TestMain_ChannelsE2E(t *testing.T) {
router := setupTest(t)
authUser := fixture.GetMockUser()
cookie := ""
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel("")
testCases := []struct {
name string
setupRequest func() (*http.Request, error)
setupHeaders func(t *testing.T, request *http.Request)
checkResponse func(recorder *httptest.ResponseRecorder)
}{
{
name: "Register Account",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": authUser.Username,
"password": authUser.Password,
"email": authUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, authUser.Username, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.Equal(t, authUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.Contains(t, recorder.Header(), "Set-Cookie")
authUser.ID = respBody.ID
cookie = recorder.Header().Get("Set-Cookie")
},
},
{
name: "Create Guild",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"name": mockGuild.Name,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/guilds/create", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockGuild.Name, respBody.Name)
assert.Equal(t, authUser.ID, respBody.OwnerId)
assert.NotNil(t, respBody.Id)
assert.Nil(t, respBody.Icon)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.False(t, respBody.HasNotification)
assert.NotNil(t, respBody.DefaultChannelId)
mockGuild.ID = respBody.Id
mockGuild.OwnerId = authUser.ID
},
},
{
name: "Create Channel",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"name": mockChannel.Name,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/channels/"+mockGuild.ID, bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.ChannelResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockChannel.Name, respBody.Name)
assert.NotNil(t, respBody.Id)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.True(t, respBody.IsPublic)
assert.False(t, respBody.HasNotification)
mockChannel.ID = respBody.Id
mockChannel.GuildID = &mockGuild.ID
},
},
{
name: "Get guild channels",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/channels/"+mockGuild.ID, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.ChannelResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
channels := *respBody
assert.Equal(t, 2, len(channels))
channel := channels[1]
assert.Equal(t, mockChannel.Name, channel.Name)
assert.Equal(t, mockChannel.ID, channel.Id)
assert.NotNil(t, channel.CreatedAt)
assert.NotNil(t, channel.UpdatedAt)
assert.True(t, channel.IsPublic)
assert.False(t, channel.HasNotification)
},
},
{
name: "Edit Channel",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"name": mockChannel.Name,
"isPublic": false,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPut, "/api/channels/"+mockChannel.ID, bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Check that the channel is private now",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/channels/"+mockGuild.ID, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.ChannelResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
channels := *respBody
assert.Equal(t, 2, len(channels))
channel := channels[1]
assert.Equal(t, mockChannel.Name, channel.Name)
assert.Equal(t, mockChannel.ID, channel.Id)
assert.NotNil(t, channel.CreatedAt)
assert.NotNil(t, channel.UpdatedAt)
assert.False(t, channel.IsPublic)
assert.False(t, channel.HasNotification)
},
},
{
name: "Get private channel members",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/channels/%s/members", mockChannel.ID)
return http.NewRequest(http.MethodGet, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
var respBody []string
err := json.Unmarshal(recorder.Body.Bytes(), &respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
assert.Equal(t, 1, len(respBody))
id := respBody[0]
assert.Equal(t, authUser.ID, id)
},
},
{
name: "Delete Channel",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodDelete, "/api/channels/"+mockChannel.ID, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Check that the channel is got deleted",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/channels/"+mockGuild.ID, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.ChannelResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
channels := *respBody
assert.Equal(t, 1, len(channels))
channel := channels[0]
assert.Equal(t, "general", channel.Name)
assert.NotNil(t, channel.Id)
assert.NotNil(t, channel.CreatedAt)
assert.NotNil(t, channel.UpdatedAt)
assert.True(t, channel.IsPublic)
assert.False(t, channel.HasNotification)
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
request, err := tc.setupRequest()
tc.setupHeaders(t, request)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
tc.checkResponse(rr)
})
}
}
func TestMain_DMsE2E(t *testing.T) {
router := setupTest(t)
authUser := fixture.GetMockUser()
cookie := ""
mockUser := fixture.GetMockUser()
dmId := ""
testCases := []struct {
name string
setupRequest func() (*http.Request, error)
setupHeaders func(t *testing.T, request *http.Request)
checkResponse func(recorder *httptest.ResponseRecorder)
}{
{
name: "Register Account",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": authUser.Username,
"password": authUser.Password,
"email": authUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, authUser.Username, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.Equal(t, authUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.Contains(t, recorder.Header(), "Set-Cookie")
authUser.ID = respBody.ID
cookie = recorder.Header().Get("Set-Cookie")
},
},
{
name: "Register Member",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": mockUser.Username,
"password": mockUser.Password,
"email": mockUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockUser.Username, respBody.Username)
assert.Equal(t, mockUser.Email, respBody.Email)
assert.Equal(t, mockUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.Image)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
mockUser.ID = respBody.ID
},
},
{
name: "Start a DM",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/channels/%s/dm", mockUser.ID)
return http.NewRequest(http.MethodPost, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Add("Cookie", cookie)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &model.DirectMessage{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody.User)
assert.NotNil(t, respBody.Id)
assert.Equal(t, mockUser.Username, respBody.User.Username)
assert.Equal(t, mockUser.ID, respBody.User.Id)
assert.Equal(t, mockUser.Image, respBody.User.Image)
assert.True(t, respBody.User.IsOnline)
assert.False(t, respBody.User.IsFriend)
dmId = respBody.Id
},
},
{
name: "Get authUser's DMs",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/channels/me/dm", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Add("Cookie", cookie)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.DirectMessage{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
dms := *respBody
assert.Len(t, dms, 1)
dm := dms[0]
assert.NotNil(t, dm.User)
assert.NotNil(t, dm.Id)
assert.Equal(t, mockUser.Username, dm.User.Username)
assert.Equal(t, mockUser.ID, dm.User.Id)
assert.Equal(t, mockUser.Image, dm.User.Image)
assert.True(t, dm.User.IsOnline)
assert.False(t, dm.User.IsFriend)
},
},
{
name: "Close DM",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/channels/%s/dm", dmId)
return http.NewRequest(http.MethodDelete, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Verify the user does not have any open DMs",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/channels/me/dm", nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Add("Cookie", cookie)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.DirectMessage{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
dms := *respBody
assert.Len(t, dms, 0)
},
},
{
name: "Get the already existing DM",
setupRequest: func() (*http.Request, error) {
reqUrl := fmt.Sprintf("/api/channels/%s/dm", mockUser.ID)
return http.NewRequest(http.MethodPost, reqUrl, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
request.Header.Add("Cookie", cookie)
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &model.DirectMessage{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody.User)
assert.NotNil(t, respBody.Id)
assert.Equal(t, mockUser.Username, respBody.User.Username)
assert.Equal(t, mockUser.ID, respBody.User.Id)
assert.Equal(t, mockUser.Image, respBody.User.Image)
assert.True(t, respBody.User.IsOnline)
assert.False(t, respBody.User.IsFriend)
assert.Equal(t, dmId, respBody.Id)
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
request, err := tc.setupRequest()
tc.setupHeaders(t, request)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
tc.checkResponse(rr)
})
}
}
func TestMain_MessagesE2E(t *testing.T) {
router := setupTest(t)
authUser := fixture.GetMockUser()
cookie := ""
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel("")
mockMessage := fixture.GetMockMessage("", "")
mockText := fixture.RandStringRunes(10)
testCases := []struct {
name string
setupRequest func() (*http.Request, error)
setupHeaders func(t *testing.T, request *http.Request)
checkResponse func(recorder *httptest.ResponseRecorder)
}{
{
name: "Register Account",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"username": authUser.Username,
"password": authUser.Password,
"email": authUser.Email,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.User{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, authUser.Username, respBody.Username)
assert.Equal(t, authUser.Email, respBody.Email)
assert.Equal(t, authUser.Image, respBody.Image)
assert.True(t, respBody.IsOnline)
assert.NotNil(t, respBody.ID)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.Contains(t, recorder.Header(), "Set-Cookie")
authUser.ID = respBody.ID
cookie = recorder.Header().Get("Set-Cookie")
},
},
{
name: "Create Guild",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"name": mockGuild.Name,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/guilds/create", bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.GuildResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockGuild.Name, respBody.Name)
assert.Equal(t, authUser.ID, respBody.OwnerId)
assert.NotNil(t, respBody.Id)
assert.Nil(t, respBody.Icon)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.False(t, respBody.HasNotification)
assert.NotNil(t, respBody.DefaultChannelId)
mockGuild.ID = respBody.Id
mockGuild.OwnerId = authUser.ID
},
},
{
name: "Create Channel",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"name": mockChannel.Name,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/channels/"+mockGuild.ID, bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody := &model.ChannelResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.Equal(t, mockChannel.Name, respBody.Name)
assert.NotNil(t, respBody.Id)
assert.NotNil(t, respBody.CreatedAt)
assert.NotNil(t, respBody.UpdatedAt)
assert.True(t, respBody.IsPublic)
assert.False(t, respBody.HasNotification)
mockChannel.ID = respBody.Id
mockChannel.GuildID = &mockGuild.ID
},
},
{
name: "Create Message",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"text": *mockMessage.Text,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPost, "/api/messages/"+mockChannel.ID, bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Get channel messages",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/messages/"+mockChannel.ID, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.MessageResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
messages := *respBody
assert.Len(t, messages, 1)
message := messages[0]
assert.Equal(t, message.Text, mockMessage.Text)
assert.NotNil(t, message.Id)
assert.NotNil(t, message.CreatedAt)
assert.NotNil(t, message.UpdatedAt)
assert.NotNil(t, message.User)
assert.Nil(t, message.Attachment)
author := message.User
assert.Equal(t, authUser.Username, author.Username)
assert.Equal(t, authUser.ID, author.Id)
assert.True(t, author.IsOnline)
assert.NotNil(t, author.Image)
assert.NotNil(t, author.CreatedAt)
assert.NotNil(t, author.UpdatedAt)
assert.Nil(t, author.Nickname)
assert.Nil(t, author.Color)
mockMessage.ID = message.Id
},
},
{
name: "Edit Message",
setupRequest: func() (*http.Request, error) {
data := gin.H{
"text": mockText,
}
reqBody, err := json.Marshal(data)
assert.NoError(t, err)
return http.NewRequest(http.MethodPut, "/api/messages/"+mockMessage.ID, bytes.NewBuffer(reqBody))
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Verify message got edited",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/messages/"+mockChannel.ID, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.MessageResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
messages := *respBody
assert.Len(t, messages, 1)
message := messages[0]
assert.Equal(t, *message.Text, mockText)
assert.NotNil(t, message.Id)
assert.NotNil(t, message.CreatedAt)
assert.NotNil(t, message.UpdatedAt)
assert.NotNil(t, message.User)
assert.Nil(t, message.Attachment)
author := message.User
assert.Equal(t, authUser.Username, author.Username)
assert.Equal(t, authUser.ID, author.Id)
assert.True(t, author.IsOnline)
assert.NotNil(t, author.Image)
assert.NotNil(t, author.CreatedAt)
assert.NotNil(t, author.UpdatedAt)
assert.Nil(t, author.Nickname)
assert.Nil(t, author.Color)
},
},
{
name: "Delete Message",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodDelete, "/api/messages/"+mockMessage.ID, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, recorder.Body.Bytes(), respBody)
},
},
{
name: "Verify message got deleted",
setupRequest: func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, "/api/messages/"+mockChannel.ID, nil)
},
setupHeaders: func(t *testing.T, request *http.Request) {
request.Header.Add("Cookie", cookie)
request.Header.Set("Content-Type", "application/json")
},
checkResponse: func(recorder *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusOK, recorder.Code)
respBody := &[]model.MessageResponse{}
err := json.Unmarshal(recorder.Body.Bytes(), respBody)
assert.NoError(t, err)
assert.NotNil(t, respBody)
messages := *respBody
assert.Len(t, messages, 0)
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
request, err := tc.setupRequest()
tc.setupHeaders(t, request)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
tc.checkResponse(rr)
})
}
}
================================================
FILE: server/go.mod
================================================
module github.com/sentrionic/valkyrie
go 1.20
// +heroku goVersion go1.20
require (
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/aws/aws-sdk-go v1.44.289
github.com/bwmarrin/snowflake v0.3.0
github.com/disintegration/imaging v1.6.2
github.com/gin-contrib/sessions v0.0.5
github.com/gin-gonic/contrib v0.0.0-20221130124618-7e01895a63f2
github.com/gin-gonic/gin v1.9.1
github.com/go-openapi/spec v0.20.9 // indirect
github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/gorilla/websocket v1.5.0
github.com/joho/godotenv v1.5.1
github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/lib/pq v1.10.9
github.com/mailru/easyjson v0.7.7 // indirect
github.com/matoous/go-nanoid v1.5.0
github.com/matoous/go-nanoid/v2 v2.0.0
github.com/rs/cors v1.9.0 // indirect
github.com/stretchr/testify v1.8.3
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.1
github.com/ulule/limiter/v3 v3.11.2
golang.org/x/crypto v0.10.0
golang.org/x/net v0.11.0 // indirect
golang.org/x/sys v0.9.0 // indirect
golang.org/x/tools v0.10.0 // indirect
gorm.io/driver/postgres v1.5.2
gorm.io/gorm v1.25.1
)
require (
github.com/redis/go-redis/v9 v9.0.5
github.com/rs/cors/wrapper/gin v0.0.0-20230526135330-e90f16747950
github.com/sethvargo/go-envconfig v0.9.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
github.com/bytedance/sonic v1.9.2 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gomodule/redigo v2.0.0+incompatible // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/image v0.8.0 // indirect
golang.org/x/text v0.10.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================================================
FILE: server/go.sum
================================================
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.44.289 h1:5CVEjiHFvdiVlKPBzv0rjG4zH/21W/onT18R5AH/qx0=
github.com/aws/aws-sdk-go v1.44.289/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.2 h1:GDaNjuWSGu09guE9Oql0MSTNhNCLlWwO8y/xM5BzcbM=
github.com/bytedance/sonic v1.9.2/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/contrib v0.0.0-20221130124618-7e01895a63f2 h1:dyuNlYlG1faymw39NdJddnzJICy6587tiGSVioWhYoE=
github.com/gin-gonic/contrib v0.0.0-20221130124618-7e01895a63f2/go.mod h1:iqneQ2Df3omzIVTkIfn7c1acsVnMGiSLn4XF5Blh3Yg=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8=
github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.1 h1:oKfB/FhuVtit1bBM3zNRRsZ925ZkMN3HXL+LgLUM9lE=
github.com/jackc/pgx/v5 v5.4.1/go.mod h1:q6iHT8uDNXWiFNOlRqJzBTaSH3+2xCXkokxHZC5qWFY=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matoous/go-nanoid v1.5.0 h1:VRorl6uCngneC4oUQqOYtO3S0H5QKFtKuKycFG3euek=
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o=
github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/cors v1.9.0 h1:l9HGsTsHJcvW14Nk7J9KFz8bzeAWXn3CG6bgt7LsrAE=
github.com/rs/cors v1.9.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/cors/wrapper/gin v0.0.0-20230526135330-e90f16747950 h1:AqLt1PEuscqbMJkmkfOw1xLlDH0VIQzrDEuOGggv0a4=
github.com/rs/cors/wrapper/gin v0.0.0-20230526135330-e90f16747950/go.mod h1:gmu40DuK3SLdKUzGOUofS3UDZwyeOUy6ZjPPuaALatw=
github.com/sethvargo/go-envconfig v0.9.0 h1:Q6FQ6hVEeTECULvkJZakq3dZMeBQ3JUpcKMfPQbKMDE=
github.com/sethvargo/go-envconfig v0.9.0/go.mod h1:Iz1Gy1Sf3T64TQlJSvee81qDhf7YIlt8GMUX6yyNFs0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.1 h1:fTNRhKstPKxcnoKsytm4sahr8FaYzUcT7i1/3nd/fBg=
github.com/swaggo/swag v1.16.1/go.mod h1:9/LMvHycG3NFHfR6LwvikHv5iFvmPADQ359cKikGxto=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA=
github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.8.0 h1:agUcRXV/+w6L9ryntYYsF2x9fQTMd4T8fiiYXAVW6Jg=
golang.org/x/image v0.8.0/go.mod h1:PwLxp3opCYg4WR2WO9P0L6ESnsD6bLTWcw8zanLMVFM=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg=
golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0=
gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8=
gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64=
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
================================================
FILE: server/handler/account_handler.go
================================================
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/sentrionic/valkyrie/model/apperrors"
"log"
"mime/multipart"
"net/http"
"strings"
)
/*
* AccountHandler contains all routes related to account actions (/api/account)
* that the authenticated user can do
*/
// GetCurrent handler calls services for getting
// a user's details
// GetCurrent godoc
// @Tags Account
// @Summary Get Current User
// @Produce json
// @Success 200 {object} model.User
// @Failure 404 {object} model.ErrorResponse
// @Router /account [get]
func (h *Handler) GetCurrent(c *gin.Context) {
userId := c.MustGet("userId").(string)
user, err := h.userService.Get(userId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", userId, err)
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, user)
}
type editReq struct {
// Min 3, max 30 characters.
Username string `form:"username"`
// Must be unique
Email string `form:"email"`
// image/png or image/jpeg
Image *multipart.FileHeader `form:"image" swaggertype:"string" format:"binary"`
} //@name EditUser
func (r editReq) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Email, validation.Required, is.EmailFormat),
validation.Field(&r.Username, validation.Required, validation.Length(3, 30)),
)
}
func (r *editReq) sanitize() {
r.Username = strings.TrimSpace(r.Username)
r.Email = strings.TrimSpace(r.Email)
r.Email = strings.ToLower(r.Email)
}
// Edit handler edits the users account details
// Edit godoc
// @Tags Account
// @Summary Update Current User
// @Accept mpfd
// @Produce json
// @Param account body editReq true "Update Account"
// @Success 200 {object} model.User
// @Failure 400 {object} model.ErrorsResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /account [put]
func (h *Handler) Edit(c *gin.Context) {
userId := c.MustGet("userId").(string)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.MaxBodyBytes)
var req editReq
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
authUser, err := h.userService.Get(userId)
if err != nil {
e := apperrors.NewAuthorization(apperrors.InvalidSession)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
authUser.Username = req.Username
// New email, check if it's unique
if authUser.Email != req.Email {
inUse := h.userService.IsEmailAlreadyInUse(req.Email)
if inUse {
toFieldErrorResponse(c, "Email", apperrors.DuplicateEmail)
return
}
authUser.Email = req.Email
}
if req.Image != nil {
// Validate image mime-type is allowable
mimeType := req.Image.Header.Get("Content-Type")
if valid := isAllowedImageType(mimeType); !valid {
toFieldErrorResponse(c, "Image", apperrors.InvalidImageType)
return
}
directory := fmt.Sprintf("valkyrie/users/%s", authUser.ID)
url, err := h.userService.ChangeAvatar(req.Image, directory)
if err != nil {
e := apperrors.NewInternal()
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
_ = h.userService.DeleteImage(authUser.Image)
authUser.Image = url
}
err = h.userService.UpdateAccount(authUser)
if err != nil {
e := apperrors.NewInternal()
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, authUser)
}
type changeRequest struct {
CurrentPassword string `json:"currentPassword"`
// Min 6, max 150 characters.
NewPassword string `json:"newPassword"`
// Must be the same as the newPassword value.
ConfirmNewPassword string `json:"confirmNewPassword"`
} //@name ChangePasswordRequest
func (r changeRequest) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.CurrentPassword, validation.Required, validation.Length(6, 150)),
validation.Field(&r.NewPassword, validation.Required, validation.Length(6, 150)),
validation.Field(&r.ConfirmNewPassword, validation.Required, validation.Length(6, 150)),
)
}
func (r *changeRequest) sanitize() {
r.CurrentPassword = strings.TrimSpace(r.CurrentPassword)
r.NewPassword = strings.TrimSpace(r.NewPassword)
r.ConfirmNewPassword = strings.TrimSpace(r.ConfirmNewPassword)
}
// ChangePassword handler changes the user's password
// ChangePassword godoc
// @Tags Account
// @Summary Change Current User's Password
// @Accept json
// @Produce json
// @Param request body changeRequest true "Change Password"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorsResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /account/change-password [put]
func (h *Handler) ChangePassword(c *gin.Context) {
userId := c.MustGet("userId").(string)
var req changeRequest
// Bind incoming json to struct and check for validation errors
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
// Check if passwords are equal
if req.NewPassword != req.ConfirmNewPassword {
toFieldErrorResponse(c, "password", apperrors.PasswordsDoNotMatch)
return
}
authUser, err := h.userService.Get(userId)
if err != nil {
e := apperrors.NewAuthorization(apperrors.InvalidSession)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
err = h.userService.ChangePassword(req.CurrentPassword, req.NewPassword, authUser)
if err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
c.JSON(http.StatusOK, true)
}
================================================
FILE: server/handler/account_handler_test.go
================================================
package handler
import (
"bytes"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/sentrionic/valkyrie/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestHandler_GetCurrent(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
t.Run("Success", func(t *testing.T) {
uid := service.GenerateId()
mockUserResp := fixture.GetMockUser()
mockUserResp.ID = uid
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(mockUserResp, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(uid)
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
request, err := http.NewRequest(http.MethodGet, "/api/account", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(mockUserResp)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertExpectations(t)
})
t.Run("NotFound", func(t *testing.T) {
uid := service.GenerateId()
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(nil, fmt.Errorf("some error down call chain"))
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(uid)
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
request, err := http.NewRequest(http.MethodGet, "/api/account", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respErr := apperrors.NewNotFound("user", uid)
respBody, err := json.Marshal(gin.H{
"error": respErr,
})
assert.NoError(t, err)
assert.Equal(t, respErr.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertExpectations(t) // assert that UserService.Get was called
})
t.Run("Unauthorized", func(t *testing.T) {
uid := service.GenerateId()
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(nil, nil)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
request, err := http.NewRequest(http.MethodGet, "/api/account", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockUserService.AssertNotCalled(t, "Get", uid)
})
}
func TestHandler_EditAccount(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
uid := service.GenerateId()
mockUser := fixture.GetMockUser()
mockUser.ID = uid
t.Run("Unauthorized", func(t *testing.T) {
router := getTestRouter()
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(mockUser, nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
rr := httptest.NewRecorder()
newName := fixture.Username()
newEmail := fixture.Email()
form := url.Values{}
form.Add("username", newName)
form.Add("email", newEmail)
request, _ := http.NewRequest(http.MethodPut, "/api/account", strings.NewReader(form.Encode()))
request.Form = form
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockUserService.AssertNotCalled(t, "UpdateAccount")
})
t.Run("UpdateAccount success", func(t *testing.T) {
router := getAuthenticatedTestRouter(uid)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(mockUser, nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
newName := fixture.Username()
newEmail := fixture.Email()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("username", newName)
_ = writer.WriteField("email", newEmail)
_ = writer.Close()
request, _ := http.NewRequest(http.MethodPut, "/api/account", body)
request.Header.Set("Content-Type", writer.FormDataContentType())
mockUser.Username = newName
mockUser.Email = newEmail
UpdateAccountArgs := mock.Arguments{
mockUser,
}
dbImageURL := "https://website.com/696292a38f493a4283d1a308e4a11732/84d81/Profile.jpg"
mockUserService.
On("UpdateAccount", UpdateAccountArgs...).
Run(func(args mock.Arguments) {
userArg := args.Get(0).(*model.User)
userArg.Image = dbImageURL
}).
Return(nil)
router.ServeHTTP(rr, request)
mockUser.Image = dbImageURL
respBody, _ := json.Marshal(mockUser)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "UpdateAccount", UpdateAccountArgs...)
})
t.Run("UpdateAccount Failure", func(t *testing.T) {
router := getAuthenticatedTestRouter(uid)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(mockUser, nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
form := url.Values{}
form.Add("username", mockUser.Username)
form.Add("email", mockUser.Email)
request, _ := http.NewRequest(http.MethodPut, "/api/account", strings.NewReader(form.Encode()))
request.Form = form
mockError := apperrors.NewInternal()
mockUserService.
On("UpdateAccount", mockUser).
Return(mockError)
router.ServeHTTP(rr, request)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "UpdateAccount", mockUser)
})
t.Run("Disallowed mimetype", func(t *testing.T) {
router := getAuthenticatedTestRouter(uid)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(mockUser, nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
multipartImageFixture := fixture.NewMultipartImage("image.txt", "mage/svg+xml")
defer multipartImageFixture.Close()
request, _ := http.NewRequest(http.MethodPut, "/api/account", multipartImageFixture.MultipartBody)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockUserService.AssertNotCalled(t, "ChangeAvatar")
})
t.Run("Email already in use", func(t *testing.T) {
router := getAuthenticatedTestRouter(uid)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(mockUser, nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
form := url.Values{}
duplicateEmail := "duplicate@example.com"
form.Add("username", mockUser.Username)
form.Add("email", duplicateEmail)
request, _ := http.NewRequest(http.MethodPut, "/api/account", strings.NewReader(form.Encode()))
request.Form = form
mockUserService.
On("IsEmailAlreadyInUse", duplicateEmail).
Return(true)
router.ServeHTTP(rr, request)
respBody, _ := json.Marshal(getTestFieldErrorResponse("Email", apperrors.DuplicateEmail))
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertNotCalled(t, "UpdateAccount", mockUser)
})
}
func TestHandler_ChangePassword(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
uid := service.GenerateId()
mockUser := fixture.GetMockUser()
mockUser.ID = uid
t.Run("Unauthorized", func(t *testing.T) {
router := getTestRouter()
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(mockUser, nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"currentPassword": "password",
"newPassword": "password!",
"confirmNewPassword": "password!",
})
assert.NoError(t, err)
request, _ := http.NewRequest(http.MethodPut, "/api/account/change-password", bytes.NewBuffer(reqBody))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockUserService.AssertNotCalled(t, "ChangePassword")
})
t.Run("ChangePassword success", func(t *testing.T) {
router := getAuthenticatedTestRouter(uid)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(mockUser, nil)
currentPassword := mockUser.Password
newPassword := "password!"
ChangePasswordArgs := mock.Arguments{
currentPassword,
newPassword,
mockUser,
}
mockUserService.
On("ChangePassword", ChangePasswordArgs...).
Return(nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"currentPassword": currentPassword,
"newPassword": newPassword,
"confirmNewPassword": newPassword,
})
assert.NoError(t, err)
request, _ := http.NewRequest(http.MethodPut, "/api/account/change-password", bytes.NewBuffer(reqBody))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, _ := json.Marshal(true)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "ChangePassword", ChangePasswordArgs...)
})
t.Run("ChangePassword Failure", func(t *testing.T) {
router := getAuthenticatedTestRouter(uid)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(mockUser, nil)
currentPassword := mockUser.Password
newPassword := "password!"
ChangePasswordArgs := mock.Arguments{
currentPassword,
newPassword,
mockUser,
}
mockError := apperrors.NewInternal()
mockUserService.
On("ChangePassword", ChangePasswordArgs...).
Return(mockError)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"currentPassword": currentPassword,
"newPassword": newPassword,
"confirmNewPassword": newPassword,
})
assert.NoError(t, err)
request, _ := http.NewRequest(http.MethodPut, "/api/account/change-password", bytes.NewBuffer(reqBody))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "ChangePassword", ChangePasswordArgs...)
})
}
func TestHandler_ChangePassword_BadRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
uid := service.GenerateId()
mockUser := fixture.GetMockUser()
mockUser.ID = uid
router := getAuthenticatedTestRouter(uid)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", uid).Return(mockUser, nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
password := fixture.RandStringRunes(6)
confirmPassword := password
testCases := []struct {
name string
body gin.H
}{
{
name: "CurrentPassword required",
body: gin.H{
"newPassword": password,
"confirmNewPassword": confirmPassword,
},
},
{
name: "NewPassword too short",
body: gin.H{
"currentPassword": fixture.RandStringRunes(6),
"newPassword": fixture.RandStringRunes(5),
"confirmNewPassword": confirmPassword,
},
},
{
name: "NewPassword too long",
body: gin.H{
"currentPassword": fixture.RandStringRunes(6),
"newPassword": fixture.RandStringRunes(151),
"confirmNewPassword": confirmPassword,
},
},
{
name: "NewPassword required",
body: gin.H{
"currentPassword": fixture.RandStringRunes(6),
"confirmNewPassword": fixture.RandStringRunes(6),
},
},
{
name: "ConfirmNewPassword too short",
body: gin.H{
"currentPassword": fixture.RandStringRunes(6),
"newPassword": fixture.RandStringRunes(6),
"confirmNewPassword": fixture.RandStringRunes(5),
},
},
{
name: "ConfirmNewPassword too long",
body: gin.H{
"currentPassword": fixture.RandStringRunes(6),
"newPassword": fixture.RandStringRunes(6),
"confirmNewPassword": fixture.RandStringRunes(151),
},
},
{
name: "ConfirmNewPassword required",
body: gin.H{
"currentPassword": fixture.RandStringRunes(6),
"newPassword": fixture.RandStringRunes(6),
},
},
{
name: "NewPassword and ConfirmNewPassword not equal",
body: gin.H{
"currentPassword": fixture.RandStringRunes(6),
"newPassword": fixture.RandStringRunes(6),
"confirmNewPassword": fixture.RandStringRunes(6),
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(tc.body)
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPut, "/api/account/change-password", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockUserService.AssertNotCalled(t, "ChangePassword")
})
}
}
================================================
FILE: server/handler/auth_handler.go
================================================
package handler
import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"log"
"net/http"
"strings"
)
/*
* AuthHandler contains all routes related to account actions (/api/account)
*/
type registerReq struct {
// Must be unique
Email string `json:"email"`
// Min 3, max 30 characters.
Username string `json:"username"`
// Min 6, max 150 characters.
Password string `json:"password"`
} //@name RegisterRequest
func (r registerReq) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Email, validation.Required, is.EmailFormat),
validation.Field(&r.Username, validation.Required, validation.Length(3, 30)),
validation.Field(&r.Password, validation.Required, validation.Length(6, 150)),
)
}
func (r *registerReq) sanitize() {
r.Username = strings.TrimSpace(r.Username)
r.Email = strings.TrimSpace(r.Email)
r.Email = strings.ToLower(r.Email)
r.Password = strings.TrimSpace(r.Password)
}
// Register handler creates a new user
// Register godoc
// @Tags Account
// @Summary Create an Account
// @Accept json
// @Produce json
// @Param account body registerReq true "Create account"
// @Success 201 {object} model.User
// @Failure 400 {object} model.ErrorsResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /account/register [post]
func (h *Handler) Register(c *gin.Context) {
var req registerReq
// Bind incoming json to struct and check for validation errors
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
initial := &model.User{
Email: req.Email,
Username: req.Username,
Password: req.Password,
}
user, err := h.userService.Register(initial)
if err != nil {
if err.Error() == apperrors.NewBadRequest(apperrors.DuplicateEmail).Error() {
toFieldErrorResponse(c, "Email", apperrors.DuplicateEmail)
return
}
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
setUserSession(c, user.ID)
c.JSON(http.StatusCreated, user)
}
type loginReq struct {
// Must be unique
Email string `json:"email"`
// Min 6, max 150 characters.
Password string `json:"password"`
} //@name LoginRequest
func (r loginReq) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Email, validation.Required, is.EmailFormat),
validation.Field(&r.Password, validation.Required, validation.Length(6, 150)),
)
}
func (r *loginReq) sanitize() {
r.Email = strings.TrimSpace(r.Email)
r.Email = strings.ToLower(r.Email)
r.Password = strings.TrimSpace(r.Password)
}
// Login used to authenticate existent user
// Login godoc
// @Tags Account
// @Summary User Login
// @Accept json
// @Produce json
// @Param account body loginReq true "Login account"
// @Success 200 {object} model.User
// @Failure 400 {object} model.ErrorsResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /account/login [post]
func (h *Handler) Login(c *gin.Context) {
var req loginReq
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
user, err := h.userService.Login(req.Email, req.Password)
if err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
setUserSession(c, user.ID)
c.JSON(http.StatusOK, user)
}
// Logout handler removes the current session
// Logout godoc
// @Tags Account
// @Summary User Logout
// @Accept json
// @Produce json
// @Param account body loginReq true "Login account"
// @Success 200 {object} model.Success
// @Router /account/logout [post]
func (h *Handler) Logout(c *gin.Context) {
c.Set("user", nil)
session := sessions.Default(c)
session.Set("userId", "")
session.Clear()
session.Options(sessions.Options{Path: "/", MaxAge: -1})
err := session.Save()
if err != nil {
log.Printf("error clearing session: %v\n", err.Error())
}
c.JSON(http.StatusOK, true)
}
type forgotRequest struct {
Email string `json:"email"`
} //@name ForgotPasswordRequest
func (r forgotRequest) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Email, validation.Required, is.EmailFormat),
)
}
func (r *forgotRequest) sanitize() {
r.Email = strings.TrimSpace(r.Email)
r.Email = strings.ToLower(r.Email)
}
// ForgotPassword sends a password reset email to the requested email
// ForgotPassword godoc
// @Tags Account
// @Summary Forgot Password Request
// @Accept json
// @Produce json
// @Param email body forgotRequest true "Forgot Password"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorsResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /account/forgot-password [post]
func (h *Handler) ForgotPassword(c *gin.Context) {
var req forgotRequest
if valid := bindData(c, &req); !valid {
return
}
req.sanitize()
user, err := h.userService.GetByEmail(req.Email)
if err != nil {
// No user with the email found
if err.Error() == apperrors.NewNotFound("email", req.Email).Error() {
c.JSON(http.StatusOK, true)
return
}
e := apperrors.NewInternal()
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
ctx := c.Request.Context()
err = h.userService.ForgotPassword(ctx, user)
if err != nil {
e := apperrors.NewInternal()
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, true)
}
type resetRequest struct {
// The token the user got from the email.
Token string `json:"token"`
// Min 6, max 150 characters.
Password string `json:"newPassword"`
// Must be the same as the password value.
ConfirmPassword string `json:"confirmNewPassword"`
} //@name ResetPasswordRequest
func (r resetRequest) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Token, validation.Required),
validation.Field(&r.Password, validation.Required, validation.Length(6, 150)),
validation.Field(&r.ConfirmPassword, validation.Required, validation.Length(6, 150)),
)
}
func (r *resetRequest) sanitize() {
r.Token = strings.TrimSpace(r.Token)
r.Password = strings.TrimSpace(r.Password)
r.ConfirmPassword = strings.TrimSpace(r.ConfirmPassword)
}
// ResetPassword resets the user's password with the provided token
// ResetPassword godoc
// @Tags Account
// @Summary Reset Password
// @Accept json
// @Produce json
// @Param request body resetRequest true "Reset Password"
// @Success 200 {object} model.User
// @Failure 400 {object} model.ErrorsResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /account/reset-password [post]
func (h *Handler) ResetPassword(c *gin.Context) {
var req resetRequest
if valid := bindData(c, &req); !valid {
return
}
req.sanitize()
// Check if passwords match
if req.Password != req.ConfirmPassword {
toFieldErrorResponse(c, "Password", apperrors.PasswordsDoNotMatch)
return
}
ctx := c.Request.Context()
user, err := h.userService.ResetPassword(ctx, req.Password, req.Token)
if err != nil {
if err.Error() == apperrors.NewBadRequest(apperrors.InvalidResetToken).Error() {
toFieldErrorResponse(c, "Token", apperrors.InvalidResetToken)
return
}
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
setUserSession(c, user.ID)
c.JSON(http.StatusOK, user)
}
================================================
FILE: server/handler/auth_handler_test.go
================================================
package handler
import (
"bytes"
"encoding/json"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/sentrionic/valkyrie/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandler_Register(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
user := fixture.GetMockUser()
reqUser := &model.User{
Email: user.Email,
Password: user.Password,
Username: user.Username,
}
t.Run("Email, Username and Password Required", func(t *testing.T) {
// We just want this to show that it's not called in this case
mockUserService := new(mocks.UserService)
mockUserService.On("Register", mock.AnythingOfType("*model.User")).Return(nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// don't need a middleware as we don't yet have authorized user
router := gin.Default()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
// create a request body with empty email and password
reqBody, err := json.Marshal(gin.H{
"email": "",
"username": "",
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, 400, rr.Code)
mockUserService.AssertNotCalled(t, "Register")
})
t.Run("Invalid email", func(t *testing.T) {
// We just want this to show that it's not called in this case
mockUserService := new(mocks.UserService)
mockUserService.On("Register", mock.AnythingOfType("*model.User")).Return(nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// don't need a middleware as we don't yet have authorized user
router := gin.Default()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
// create a request body with empty email and password
reqBody, err := json.Marshal(gin.H{
"email": "bob@bob",
"username": "bobby",
"password": "supersecret1234",
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, 400, rr.Code)
mockUserService.AssertNotCalled(t, "Signup")
})
t.Run("Username too short", func(t *testing.T) {
// We just want this to show that it's not called in this case
mockUserService := new(mocks.UserService)
mockUserService.On("Register", mock.AnythingOfType("*model.User")).Return(nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// don't need a middleware as we don't yet have authorized user
router := gin.Default()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
// create a request body with empty email and password
reqBody, err := json.Marshal(gin.H{
"email": "bob@bob.com",
"username": "bo",
"password": "superpassword",
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, 400, rr.Code)
mockUserService.AssertNotCalled(t, "Register")
})
t.Run("Password too short", func(t *testing.T) {
// We just want this to show that it's not called in this case
mockUserService := new(mocks.UserService)
mockUserService.On("Register", mock.AnythingOfType("*model.User")).Return(nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// don't need a middleware as we don't yet have authorized user
router := gin.Default()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
// create a request body with empty email and password
reqBody, err := json.Marshal(gin.H{
"email": "bob@bob.com",
"username": "bobby",
"password": "supe",
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, 400, rr.Code)
mockUserService.AssertNotCalled(t, "Register")
})
t.Run("Username too long", func(t *testing.T) {
// We just want this to show that it's not called in this case
mockUserService := new(mocks.UserService)
mockUserService.On("Register", mock.AnythingOfType("*model.User")).Return(nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// don't need a middleware as we don't yet have authorized user
router := gin.Default()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
// create a request body with empty email and password
reqBody, err := json.Marshal(gin.H{
"email": "bob@bob.com",
"username": "kjhasiudaiusdiuadiuagszuidgaiszugdziasgdiazgsdiazugdipas",
"password": "superpassword",
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, 400, rr.Code)
mockUserService.AssertNotCalled(t, "Register")
})
t.Run("Error returned from UserService", func(t *testing.T) {
u := &model.User{
Email: reqUser.Email,
Username: reqUser.Username,
Password: reqUser.Password,
}
mockUserService := new(mocks.UserService)
mockUserService.On("Register", u).Return(nil, apperrors.NewConflict("User Already Exists", u.Email))
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// don't need a middleware as we don't yet have authorized user
router := gin.Default()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
// create a request body with empty email and password
reqBody, err := json.Marshal(gin.H{
"email": u.Email,
"username": u.Username,
"password": u.Password,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, 409, rr.Code)
mockUserService.AssertExpectations(t)
})
t.Run("Successful Creation", func(t *testing.T) {
u := &model.User{
Email: reqUser.Email,
Username: reqUser.Username,
Password: reqUser.Password,
}
mockUserService := new(mocks.UserService)
mockUserService.
On("Register", u).
Return(reqUser, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
// create a request body with empty email and password
reqBody, err := json.Marshal(gin.H{
"email": u.Email,
"username": u.Username,
"password": u.Password,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/account/register", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(u)
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertExpectations(t)
})
}
func TestHandler_Login(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
// setup mock services, gin engine/router, handler layer
mockUserService := new(mocks.UserService)
router := getTestRouter()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
t.Run("Bad request data", func(t *testing.T) {
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// create a request body with invalid fields
reqBody, err := json.Marshal(gin.H{
"email": "notanemail",
"password": "short",
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/account/login", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockUserService.AssertNotCalled(t, "Login")
})
t.Run("Error Returned from UserService.Login", func(t *testing.T) {
email := "bob@bob.com"
password := "pwdoesnotmatch123"
mockUSArgs := mock.Arguments{
email, password,
}
// so we can check for a known status code
mockError := apperrors.NewAuthorization("invalid email/password combo")
mockUserService.On("Login", mockUSArgs...).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// create a request body with valid fields
reqBody, err := json.Marshal(gin.H{
"email": email,
"password": password,
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/account/login", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockUserService.AssertCalled(t, "Login", mockUSArgs...)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
})
t.Run("Successful Login", func(t *testing.T) {
user := fixture.GetMockUser()
mockUSArgs := mock.Arguments{
user.Email,
user.Password,
}
mockUserService.On("Login", mockUSArgs...).Return(user, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// create a request body with valid fields
reqBody, err := json.Marshal(gin.H{
"email": user.Email,
"password": user.Password,
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/account/login", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(user)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "Login", mockUSArgs...)
})
}
func TestHandler_Logout(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("Success", func(t *testing.T) {
uid := service.GenerateId()
rr := httptest.NewRecorder()
// creates a test context for setting a user
router := getAuthenticatedTestRouter(uid)
NewHandler(&Config{
R: router,
})
request, _ := http.NewRequest(http.MethodPost, "/api/account/logout", nil)
router.ServeHTTP(rr, request)
respBody, _ := json.Marshal(true)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
router.Use(func(c *gin.Context) {
contextUserId, exists := c.Get("userId")
assert.Equal(t, exists, false)
assert.Nil(t, contextUserId)
session := sessions.Default(c)
id := session.Get("userId")
assert.Nil(t, id)
})
})
}
func TestHandler_ForgotPassword(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockUser := fixture.GetMockUser()
t.Run("ForgotPassword success", func(t *testing.T) {
router := gin.Default()
mockUserService := new(mocks.UserService)
mockUserService.On("GetByEmail", mockUser.Email).Return(mockUser, nil)
ForgotPasswordArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
mockUser,
}
mockUserService.
On("ForgotPassword", ForgotPasswordArgs...).
Return(nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"email": mockUser.Email,
})
assert.NoError(t, err)
request, _ := http.NewRequest(http.MethodPost, "/api/account/forgot-password", bytes.NewBuffer(reqBody))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, _ := json.Marshal(true)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "ForgotPassword", ForgotPasswordArgs...)
})
t.Run("ForgotPassword Failure", func(t *testing.T) {
router := gin.Default()
mockUserService := new(mocks.UserService)
mockUserService.On("GetByEmail", mockUser.Email).Return(mockUser, nil)
ForgotPasswordArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
mockUser,
}
mockError := apperrors.NewInternal()
mockUserService.
On("ForgotPassword", ForgotPasswordArgs...).
Return(mockError)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"email": mockUser.Email,
})
assert.NoError(t, err)
request, _ := http.NewRequest(http.MethodPost, "/api/account/forgot-password", bytes.NewBuffer(reqBody))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "ForgotPassword", ForgotPasswordArgs...)
})
t.Run("No user found", func(t *testing.T) {
router := gin.Default()
mockUserService := new(mocks.UserService)
mockError := apperrors.NewNotFound("email", mockUser.Email)
mockUserService.On("GetByEmail", mockUser.Email).Return(&model.User{}, mockError)
ForgotPasswordArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
mockUser,
}
mockUserService.
On("ForgotPassword", ForgotPasswordArgs...).
Return(nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"email": mockUser.Email,
})
assert.NoError(t, err)
request, _ := http.NewRequest(http.MethodPost, "/api/account/forgot-password", bytes.NewBuffer(reqBody))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, _ := json.Marshal(true)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertNotCalled(t, "ForgotPassword", ForgotPasswordArgs...)
})
}
func TestHandler_ForgotPassword_BadRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockUser := fixture.GetMockUser()
router := gin.Default()
mockUserService := new(mocks.UserService)
mockUserService.On("GetByEmail", mockUser.Email).Return(mockUser, nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
testCases := []struct {
name string
body gin.H
}{
{
name: "Email required",
body: gin.H{},
},
{
name: "Invalid Email",
body: gin.H{
"email": "invalidemail",
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(tc.body)
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/account/forgot-password", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockUserService.AssertNotCalled(t, "ForgotPassword")
})
}
}
func TestHandler_ResetPassword(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockUser := fixture.GetMockUser()
token := fixture.RandStringRunes(18)
t.Run("ResetPassword success", func(t *testing.T) {
mockUserService := new(mocks.UserService)
router := getTestRouter()
ResetPasswordArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
mockUser.Password,
token,
}
mockUserService.
On("ResetPassword", ResetPasswordArgs...).
Return(mockUser, nil)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"token": token,
"newPassword": mockUser.Password,
"confirmNewPassword": mockUser.Password,
})
assert.NoError(t, err)
request, _ := http.NewRequest(http.MethodPost, "/api/account/reset-password", bytes.NewBuffer(reqBody))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, _ := json.Marshal(mockUser)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "ResetPassword", ResetPasswordArgs...)
})
t.Run("ResetPassword Failure", func(t *testing.T) {
mockUserService := new(mocks.UserService)
router := getTestRouter()
ResetPasswordArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
mockUser.Password,
token,
}
mockError := apperrors.NewInternal()
mockUserService.
On("ResetPassword", ResetPasswordArgs...).
Return(nil, mockError)
NewHandler(&Config{
R: router,
UserService: mockUserService,
MaxBodyBytes: 4 * 1024 * 1024,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"token": token,
"newPassword": mockUser.Password,
"confirmNewPassword": mockUser.Password,
})
assert.NoError(t, err)
request, _ := http.NewRequest(http.MethodPost, "/api/account/reset-password", bytes.NewBuffer(reqBody))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockUserService.AssertCalled(t, "ResetPassword", ResetPasswordArgs...)
})
}
func TestHandler_ResetPassword_BadRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
router := gin.Default()
mockUserService := new(mocks.UserService)
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
password := fixture.RandStringRunes(6)
confirmPassword := password
testCases := []struct {
name string
body gin.H
}{
{
name: "Token required",
body: gin.H{
"newPassword": password,
"confirmNewPassword": password,
},
},
{
name: "Password required",
body: gin.H{
"token": fixture.RandStringRunes(18),
"confirmNewPassword": password,
},
},
{
name: "Password too short",
body: gin.H{
"token": fixture.RandStringRunes(18),
"newPassword": fixture.RandStringRunes(5),
"confirmNewPassword": confirmPassword,
},
},
{
name: "NewPassword too long",
body: gin.H{
"token": fixture.RandStringRunes(16),
"newPassword": fixture.RandStringRunes(151),
"confirmNewPassword": confirmPassword,
},
},
{
name: "ConfirmNewPassword too short",
body: gin.H{
"token": fixture.RandStringRunes(6),
"newPassword": fixture.RandStringRunes(6),
"confirmNewPassword": fixture.RandStringRunes(5),
},
},
{
name: "ConfirmNewPassword too long",
body: gin.H{
"currentPassword": fixture.RandStringRunes(6),
"newPassword": fixture.RandStringRunes(6),
"confirmNewPassword": fixture.RandStringRunes(151),
},
},
{
name: "ConfirmNewPassword required",
body: gin.H{
"token": fixture.RandStringRunes(6),
"newPassword": fixture.RandStringRunes(6),
},
},
{
name: "NewPassword and ConfirmNewPassword not equal",
body: gin.H{
"token": fixture.RandStringRunes(6),
"newPassword": fixture.RandStringRunes(6),
"confirmNewPassword": fixture.RandStringRunes(6),
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(tc.body)
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/account/reset-password", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockUserService.AssertNotCalled(t, "ResetPassword")
})
}
}
================================================
FILE: server/handler/bind_data.go
================================================
package handler
import (
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// Request contains the validate function which validates the request with bindData
type Request interface {
validate() error
}
// bindData is helper function, returns false if data is not bound
func bindData(c *gin.Context, req Request) bool {
// Bind incoming json to struct and check for validation errors
if err := c.ShouldBind(req); err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return false
}
if err := req.validate(); err != nil {
errors := strings.Split(err.Error(), ";")
fErrors := make([]model.FieldError, 0)
for _, e := range errors {
split := strings.Split(e, ":")
er := model.FieldError{
Field: strings.TrimSpace(split[0]),
Message: strings.TrimSpace(split[1]),
}
fErrors = append(fErrors, er)
}
c.JSON(http.StatusBadRequest, gin.H{
"errors": fErrors,
})
return false
}
return true
}
================================================
FILE: server/handler/channel_handler.go
================================================
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"log"
"net/http"
"strings"
)
/*
* ChannelHandler contains all routes related to channel actions (/api/channels)
*/
// GuildChannels returns the given guild's channels
// GuildChannels godoc
// @Tags Channels
// @Summary Get Guild Channels
// @Produce json
// @Param guildId path string true "Guild ID"
// @Success 200 {array} model.ChannelResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /channels/{guildId} [get]
func (h *Handler) GuildChannels(c *gin.Context) {
guildId := c.Param("id")
userId := c.MustGet("userId").(string)
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Only get the channels if the user is a member
if !isMember(guild, userId) {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
channels, err := h.channelService.GetChannels(userId, guildId)
if err != nil {
log.Printf("Unable to find channels for guild id: %v\n%v", guildId, err)
e := apperrors.NewNotFound("channels", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, channels)
}
// channelReq specifies the input form for creating a channel
// IsPublic and Members do not need to be specified if you want
// to create a public channel
type channelReq struct {
// Channel Name. 3 to 30 character
Name string `json:"name"`
// Default is true
IsPublic *bool `json:"isPublic"`
// Array of memberIds
Members []string `json:"members"`
} //@name ChannelRequest
func (r channelReq) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Name, validation.Required, validation.Length(3, 30)),
)
}
func (r *channelReq) sanitize() {
r.Name = strings.TrimSpace(r.Name)
}
// CreateChannel creates a channel for the given guild param
// CreateChannel godoc
// @Tags Channels
// @Summary Create Channel
// @Accepts json
// @Produce json
// @Param guildId path string true "Guild ID"
// @Success 200 {array} model.ChannelResponse
// @Failure 400 {object} model.ErrorsResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /channels/{guildId} [post]
func (h *Handler) CreateChannel(c *gin.Context) {
var req channelReq
// Bind incoming json to struct and check for validation errors
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
userId := c.MustGet("userId").(string)
guildId := c.Param("id")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if the server already has 50 channels
if len(guild.Channels) >= model.MaximumChannels {
e := apperrors.NewBadRequest(apperrors.ChannelLimitError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
channelParams := model.Channel{
Name: req.Name,
IsPublic: true,
GuildID: &guildId,
}
// Channel is private
if req.IsPublic != nil && !*req.IsPublic {
channelParams.IsPublic = false
// Add the current user to the members if they are not in there
if !containsUser(req.Members, userId) {
req.Members = append(req.Members, userId)
}
members, err := h.guildService.FindUsersByIds(req.Members, guildId)
if err != nil {
e := apperrors.NewInternal()
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Create private channel members
channelParams.PCMembers = append(channelParams.PCMembers, *members...)
}
channel, err := h.channelService.CreateChannel(&channelParams)
if err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
guild.Channels = append(guild.Channels, *channel)
if err = h.guildService.UpdateGuild(guild); err != nil {
log.Printf("Failed to update guild: %v\n", err.Error())
e := apperrors.NewInternal()
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
response := channel.SerializeChannel()
// Emit the new channel to the guild members
if channel.IsPublic {
h.socketService.EmitNewChannel(guildId, &response)
// Emit to private channel members
} else {
h.socketService.EmitNewPrivateChannel(req.Members, &response)
}
c.JSON(http.StatusCreated, response)
}
// PrivateChannelMembers returns the ids of all members
// that are part of the channel
// PrivateChannelMembers godoc
// @Tags Channels
// @Summary Get Members of the given Channel
// @Produce json
// @Param channelId path string true "Channel ID"
// @Success 200 {array} string
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /channels/{channelId}/members [get]
func (h *Handler) PrivateChannelMembers(c *gin.Context) {
channelId := c.Param("id")
userId := c.MustGet("userId").(string)
channel, err := h.channelService.Get(channelId)
if err != nil {
e := apperrors.NewNotFound("channel", channelId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if channel.GuildID == nil {
e := apperrors.NewNotFound("channel", channelId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
guild, err := h.guildService.GetGuild(*channel.GuildID)
if err != nil {
e := apperrors.NewNotFound("channel", channelId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Public channels do not have any private members
if channel.IsPublic {
var empty = make([]string, 0)
c.JSON(http.StatusOK, empty)
return
}
members, err := h.channelService.GetPrivateChannelMembers(channelId)
if err != nil {
log.Printf("Unable to find members for channel: %v\n%v", channelId, err)
e := apperrors.NewNotFound("members", channelId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, members)
}
// DirectMessages returns a list of the current users DMs
// DirectMessages godoc
// @Tags Channels
// @Summary Get User's DMs
// @Produce json
// @Success 200 {array} model.DirectMessage
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /channels/me/dm [get]
func (h *Handler) DirectMessages(c *gin.Context) {
userId := c.MustGet("userId").(string)
channels, err := h.channelService.GetDirectMessages(userId)
if err != nil {
log.Printf("Unable to find dms for user id: %v\n%v", userId, err)
e := apperrors.NewNotFound("dms", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// If the user does not have any dms, return an empty array
if len(*channels) == 0 {
var empty = make([]model.DirectMessage, 0)
c.JSON(http.StatusOK, empty)
return
}
c.JSON(http.StatusOK, channels)
}
// GetOrCreateDM gets the DM with the given member and creates it
// if it does not already exist
// DirectMessages godoc
// @Tags Channels
// @Summary Get or Create DM
// @Produce json
// @Param channelId path string true "Member ID"
// @Success 200 {object} model.DirectMessage
// @Failure 400 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /channels/{channelId}/dm [post]
func (h *Handler) GetOrCreateDM(c *gin.Context) {
userId := c.MustGet("userId").(string)
memberId := c.Param("id")
if userId == memberId {
e := apperrors.NewBadRequest(apperrors.DMYourselfError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
member, err := h.friendService.GetMemberById(memberId)
if err != nil {
log.Printf("Unable to find member for id: %v\n%v", memberId, err)
e := apperrors.NewNotFound("member", memberId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// check if dm channel already exists with these members
dmId, err := h.channelService.GetDirectMessageChannel(userId, memberId)
if err != nil {
log.Printf("Unable to find or create dms for user id: %v\n%v", userId, err)
e := apperrors.NewNotFound("dms", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// dm already exists
if dmId != nil && *dmId != "" {
_ = h.channelService.SetDirectMessageStatus(*dmId, userId, true)
c.JSON(http.StatusOK, toDMChannel(member, *dmId, userId))
return
}
// Create the dm channel between the current user and the member
id := fmt.Sprintf("%s-%s", userId, memberId)
channelParams := model.Channel{
Name: id,
IsPublic: false,
IsDM: true,
}
channel, err := h.channelService.CreateChannel(&channelParams)
// Create the DM channel
if err != nil {
log.Printf("Failed to create channel: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Add the users to it
ids := []string{userId, memberId}
err = h.channelService.AddDMChannelMembers(ids, channel.ID, userId)
if err != nil {
log.Printf("Failed to create channel: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
c.JSON(http.StatusOK, toDMChannel(member, channel.ID, userId))
}
// toDMChannel returns the DM response for the given channel and member
func toDMChannel(member *model.User, channelId string, userId string) model.DirectMessage {
return model.DirectMessage{
Id: channelId,
User: model.DMUser{
Id: member.ID,
Username: member.Username,
Image: member.Image,
IsOnline: member.IsOnline,
IsFriend: isFriend(member, userId),
},
}
}
// EditChannel edits the specified channel
// EditChannel godoc
// @Tags Channels
// @Summary Edit Channel
// @Accepts json
// @Produce json
// @Param channelId path string true "Channel ID"
// @Param request body channelReq true "Edit Channel"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorsResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /channels/{channelId} [put]
func (h *Handler) EditChannel(c *gin.Context) {
var req channelReq
// Bind incoming json to struct and check for validation errors
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
userId := c.MustGet("userId").(string)
channelId := c.Param("id")
channel, err := h.channelService.Get(channelId)
if err != nil {
e := apperrors.NewNotFound("channel", channelId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
guild, err := h.guildService.GetGuild(*channel.GuildID)
if err != nil {
e := apperrors.NewNotFound("guild", *channel.GuildID)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
isPublic := true
if req.IsPublic != nil {
isPublic = *req.IsPublic
}
// Used to be private and now is public
if isPublic && !channel.IsPublic {
err = h.channelService.CleanPCMembers(channelId)
if err != nil {
log.Printf("error removing pc members: %v", err)
}
}
channel.IsPublic = isPublic
channel.Name = req.Name
// Member Changes
if !isPublic {
// Check if the array contains the current member
if !containsUser(req.Members, userId) {
req.Members = append(req.Members, userId)
}
// Current members of the channel
current := make([]string, 0)
for _, member := range channel.PCMembers {
current = append(current, member.ID)
}
// Newly added members
newMembers := difference(req.Members, current)
// Members that got removed
toRemove := difference(current, req.Members)
err = h.channelService.AddPrivateChannelMembers(newMembers, channelId)
if err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
err = h.channelService.RemovePrivateChannelMembers(toRemove, channelId)
if err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
}
if err = h.channelService.UpdateChannel(channel); err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Emit the channel changes to the guild members
response := channel.SerializeChannel()
h.socketService.EmitEditChannel(*channel.GuildID, &response)
c.JSON(http.StatusOK, true)
}
// difference returns the elements in `a` that aren't in `b`.
func difference(a, b []string) []string {
mb := make(map[string]struct{}, len(b))
for _, x := range b {
mb[x] = struct{}{}
}
var diff []string
for _, x := range a {
if _, found := mb[x]; !found {
diff = append(diff, x)
}
}
return diff
}
// DeleteChannel removes the given channel from the guild
// DeleteChannel godoc
// @Tags Channels
// @Summary Delete Channel
// @Produce json
// @Param id path string true "Channel ID"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /channels/{id} [delete]
func (h *Handler) DeleteChannel(c *gin.Context) {
userId := c.MustGet("userId").(string)
channelId := c.Param("id")
channel, err := h.channelService.Get(channelId)
if err != nil {
e := apperrors.NewNotFound("channel", channelId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
guild, err := h.guildService.GetGuild(*channel.GuildID)
if err != nil {
e := apperrors.NewNotFound("channel", channelId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if the guild has the minimum amount of channels
if len(guild.Channels) <= model.MinimumChannels {
e := apperrors.NewBadRequest(apperrors.OneChannelRequired)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if err = h.channelService.DeleteChannel(channel); err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Emit signal to remove the channel from the guild
h.socketService.EmitDeleteChannel(channel)
c.JSON(http.StatusOK, true)
}
// CloseDM closes the DM on the current users side
// CloseDM godoc
// @Tags Channels
// @Summary Close DM
// @Produce json
// @Param id path string true "DM Channel ID"
// @Success 200 {object} model.Success
// @Failure 404 {object} model.ErrorResponse
// @Router /channels/{id}/dm [delete]
func (h *Handler) CloseDM(c *gin.Context) {
userId := c.MustGet("userId").(string)
channelId := c.Param("id")
dmId, err := h.channelService.GetDMByUserAndChannel(userId, channelId)
if err != nil || dmId == "" {
log.Printf("Unable to find or create dms for user id: %v\n%v", userId, err)
e := apperrors.NewNotFound("dms", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
_ = h.channelService.SetDirectMessageStatus(channelId, userId, false)
c.JSON(http.StatusOK, true)
}
// containsUser checks if the array contains the user
func containsUser(members []string, userId string) bool {
for _, m := range members {
if m == userId {
return true
}
}
return false
}
================================================
FILE: server/handler/channel_handler_test.go
================================================
package handler
import (
"bytes"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandler_GuildChannels(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successful Fetch", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuild.Members = append(mockGuild.Members, *authUser)
response := make([]model.ChannelResponse, 0)
for i := 0; i < 5; i++ {
mockChannel := fixture.GetMockChannel(mockGuild.ID)
response = append(response, mockChannel.SerializeChannel())
}
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("GetChannels", authUser.ID, mockGuild.ID).Return(&response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(response)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Guild not found", func(t *testing.T) {
id := fixture.RandID()
mockError := apperrors.NewNotFound("guild", id)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", id).Return(nil, mockError)
mockChannelService := new(mocks.ChannelService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s", id)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", id)
mockChannelService.AssertNotCalled(t, "GetChannels")
})
t.Run("Not a member of the guild", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockError := apperrors.NewNotFound("guild", mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockChannelService.AssertNotCalled(t, "GetChannels")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild")
mockChannelService.AssertNotCalled(t, "GetChannels")
})
t.Run("Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuild.Members = append(mockGuild.Members, *authUser)
mockError := apperrors.NewNotFound("channels", mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("GetChannels", authUser.ID, mockGuild.ID).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
}
func TestHandler_CreateChannel(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successful channel creation", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("UpdateGuild", mockGuild).Return(nil)
mockChannelService := new(mocks.ChannelService)
channelParams := &model.Channel{
Name: mockChannel.Name,
IsPublic: true,
GuildID: &mockGuild.ID,
}
mockChannelService.On("CreateChannel", channelParams).Return(mockChannel, nil)
mockSocketService := new(mocks.SocketService)
response := mockChannel.SerializeChannel()
mockSocketService.On("EmitNewChannel", mockGuild.ID, &response)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(response)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusCreated, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Guild not found", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", mockGuild.ID)
mockGuildService.On("GetGuild", mockGuild.ID).Return(nil, mockError)
mockChannelService := new(mocks.ChannelService)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockChannelService.AssertNotCalled(t, "CreateChannel")
mockSocketService.AssertNotCalled(t, "EmitNewChannel")
})
t.Run("Guild already has the maximum number of channels", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
for i := 0; i < model.MaximumChannels; i++ {
channel := fixture.GetMockChannel(mockGuild.ID)
mockGuild.Channels = append(mockGuild.Channels, *channel)
}
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewBadRequest(apperrors.ChannelLimitError)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockChannelService.AssertNotCalled(t, "CreateChannel")
mockSocketService.AssertNotCalled(t, "EmitNewChannel")
})
t.Run("Not the guild owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.MustBeOwner)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockChannelService.AssertNotCalled(t, "CreateChannel")
mockSocketService.AssertNotCalled(t, "EmitNewChannel")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
mockSocketService := new(mocks.SocketService)
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockChannelService.AssertNotCalled(t, "CreateChannel")
mockSocketService.AssertNotCalled(t, "EmitNewChannel")
})
t.Run("Server Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
channelParams := &model.Channel{
Name: mockChannel.Name,
IsPublic: true,
GuildID: &mockGuild.ID,
}
mockError := apperrors.NewInternal()
mockChannelService.On("CreateChannel", channelParams).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockChannelService.AssertExpectations(t)
mockSocketService.AssertNotCalled(t, "EmitNewChannel")
})
t.Run("Successful private channel creation", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
members := make([]model.User, 0)
members = append(members, *authUser)
reqMembers := []string{authUser.ID}
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("UpdateGuild", mockGuild).Return(nil)
mockGuildService.On("FindUsersByIds", reqMembers, mockGuild.ID).Return(&members, nil)
mockChannelService := new(mocks.ChannelService)
channelParams := &model.Channel{
Name: mockChannel.Name,
IsPublic: false,
GuildID: &mockGuild.ID,
}
channelParams.PCMembers = append(channelParams.PCMembers, *authUser)
mockChannelService.On("CreateChannel", channelParams).Return(mockChannel, nil)
mockSocketService := new(mocks.SocketService)
response := mockChannel.SerializeChannel()
mockSocketService.On("EmitNewChannel", mockGuild.ID, &response)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
"isPublic": false,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(response)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusCreated, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
}
func TestHandler_CreateChannel_BadRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockUser := fixture.GetMockUser()
router := getAuthenticatedTestRouter(mockUser.ID)
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
testCases := []struct {
name string
body gin.H
}{
{
name: "Name required",
body: gin.H{},
},
{
name: "Name too short",
body: gin.H{
"name": fixture.RandStr(2),
},
},
{
name: "Name too long",
body: gin.H{
"name": fixture.RandStr(31),
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(tc.body)
assert.NoError(t, err)
url := fmt.Sprintf("/api/channels/%s", fixture.RandID())
request, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockGuildService.AssertNotCalled(t, "GetGuild")
mockChannelService.AssertNotCalled(t, "CreateChannel")
})
}
}
func TestHandler_PrivateChannelMembers(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successful Fetch", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockChannel.IsPublic = false
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
response := make([]string, 0)
for i := 0; i < 5; i++ {
mockUser := fixture.GetMockUser()
response = append(response, mockUser.ID)
}
mockChannelService.On("GetPrivateChannelMembers", mockChannel.ID).Return(&response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s/members", mockChannel.ID)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(response)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Guild not found", func(t *testing.T) {
mockChannel := fixture.GetMockChannel("")
mockChannel.IsPublic = false
mockError := apperrors.NewNotFound("channel", mockChannel.ID)
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s/members", mockChannel.ID)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockGuildService.AssertNotCalled(t, "GetGuild")
mockChannelService.AssertNotCalled(t, "GetPrivateChannelMembers")
})
t.Run("Channel not found", func(t *testing.T) {
id := fixture.RandID()
mockError := apperrors.NewNotFound("channel", id)
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", id).Return(nil, mockError)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s/members", id)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", id)
mockGuildService.AssertNotCalled(t, "GetGuild")
mockChannelService.AssertNotCalled(t, "GetPrivateChannelMembers")
})
t.Run("Not the guild owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockChannel.IsPublic = false
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s/members", mockChannel.ID)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
respBody, err := json.Marshal(gin.H{
"error": e,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, e.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockChannelService.AssertNotCalled(t, "GetPrivateChannelMembers")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockChannel.IsPublic = false
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s/members", mockChannel.ID)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertNotCalled(t, "Get")
mockGuildService.AssertNotCalled(t, "GetGuild")
mockChannelService.AssertNotCalled(t, "GetPrivateChannelMembers")
})
t.Run("Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockChannel.IsPublic = false
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockError := apperrors.NewNotFound("members", mockChannel.ID)
mockChannelService.On("GetPrivateChannelMembers", mockChannel.ID).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
url := fmt.Sprintf("/api/channels/%s/members", mockChannel.ID)
request, err := http.NewRequest(http.MethodGet, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
}
func TestHandler_DirectMessages(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Success", func(t *testing.T) {
mockChannelService := new(mocks.ChannelService)
response := make([]model.DirectMessage, 0)
for i := 0; i < 5; i++ {
user := fixture.GetMockUser()
response = append(response, toDMChannel(user, fixture.RandID(), authUser.ID))
}
mockChannelService.On("GetDirectMessages", authUser.ID).Return(&response, nil)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, "/api/channels/me/dm", nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(response)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertExpectations(t)
})
t.Run("Unauthorized", func(t *testing.T) {
mockChannelService := new(mocks.ChannelService)
router := getTestRouter()
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, "/api/channels/me/dm", nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertNotCalled(t, "GetDirectMessages")
})
t.Run("Error", func(t *testing.T) {
mockChannelService := new(mocks.ChannelService)
mockError := apperrors.NewNotFound("dms", authUser.ID)
mockChannelService.On("GetDirectMessages", authUser.ID).Return(nil, mockError)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodGet, "/api/channels/me/dm", nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertExpectations(t)
})
}
func TestHandler_GetOrCreateDM(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully returned an already existing DM", func(t *testing.T) {
mockUser := fixture.GetMockUser()
dmId := fixture.RandID()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("GetDirectMessageChannel", authUser.ID, mockUser.ID).Return(&dmId, nil)
mockChannelService.On("SetDirectMessageStatus", dmId, authUser.ID, true).Return(nil)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s/dm", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(toDMChannel(mockUser, dmId, authUser.ID))
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockChannelService.AssertNotCalled(t, "CreateChannel")
})
t.Run("Successfully returned a new DM", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("GetDirectMessageChannel", authUser.ID, mockUser.ID).Return(nil, nil)
mockDM := fixture.GetMockDMChannel()
channelParams := &model.Channel{
Name: fmt.Sprintf("%s-%s", authUser.ID, mockUser.ID),
IsPublic: false,
IsDM: true,
}
mockChannelService.On("CreateChannel", channelParams).Return(mockDM, nil)
ids := []string{authUser.ID, mockUser.ID}
mockArgs := mock.Arguments{
ids,
mockDM.ID,
authUser.ID,
}
mockChannelService.On("AddDMChannelMembers", mockArgs...).Return(nil)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s/dm", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(toDMChannel(mockUser, mockDM.ID, authUser.ID))
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
})
t.Run("Member not found", func(t *testing.T) {
id := fixture.RandID()
mockError := apperrors.NewNotFound("member", id)
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", id).Return(nil, mockError)
mockChannelService := new(mocks.ChannelService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s/dm", id)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertCalled(t, "GetMemberById", id)
mockChannelService.AssertNotCalled(t, "GetDirectMessageChannel")
mockChannelService.AssertNotCalled(t, "CreateChannel")
})
t.Run("Member and AuthUser are the same", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockChannelService := new(mocks.ChannelService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s/dm", authUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
e := apperrors.NewBadRequest(apperrors.DMYourselfError)
respBody, _ := json.Marshal(gin.H{
"error": e,
})
router.ServeHTTP(rr, request)
assert.Equal(t, e.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertNotCalled(t, "GetMemberById")
mockChannelService.AssertNotCalled(t, "GetDirectMessageChannel")
mockChannelService.AssertNotCalled(t, "CreateChannel")
})
t.Run("Unauthorized", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockChannelService := new(mocks.ChannelService)
router := getTestRouter()
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s/dm", fixture.RandID())
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertNotCalled(t, "GetMemberById")
mockChannelService.AssertNotCalled(t, "GetDirectMessageChannel")
mockChannelService.AssertNotCalled(t, "CreateChannel")
})
t.Run("Server Error", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("GetDirectMessageChannel", authUser.ID, mockUser.ID).Return(nil, nil)
mockDM := fixture.GetMockDMChannel()
channelParams := &model.Channel{
Name: fmt.Sprintf("%s-%s", authUser.ID, mockUser.ID),
IsPublic: false,
IsDM: true,
}
mockChannelService.On("CreateChannel", channelParams).Return(mockDM, nil)
ids := []string{authUser.ID, mockUser.ID}
mockArgs := mock.Arguments{
ids,
mockDM.ID,
authUser.ID,
}
mockError := apperrors.NewInternal()
mockChannelService.On("AddDMChannelMembers", mockArgs...).Return(mockError)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s/dm", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
})
}
func TestHandler_EditChannel(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successful edited channel", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("UpdateChannel", mockChannel).Return(nil)
mockSocketService := new(mocks.SocketService)
response := mockChannel.SerializeChannel()
mockSocketService.On("EmitEditChannel", mockGuild.ID, &response)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(true)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Guild not found", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockError := apperrors.NewNotFound("guild", mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(nil, mockError)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockChannelService.AssertNotCalled(t, "UpdateChannel")
mockSocketService.AssertNotCalled(t, "EmitEditChannel")
})
t.Run("Channel not found", func(t *testing.T) {
id := fixture.RandID()
mockError := apperrors.NewNotFound("channel", id)
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", id).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": fixture.RandStr(8),
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", id)
request, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", id)
mockGuildService.AssertNotCalled(t, "GetGuild")
mockChannelService.AssertNotCalled(t, "UpdateChannel")
mockSocketService.AssertNotCalled(t, "EmitEditChannel")
})
t.Run("Not the guild owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
respBody, _ := json.Marshal(gin.H{
"error": e,
})
router.ServeHTTP(rr, request)
assert.Equal(t, e.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockChannelService.AssertNotCalled(t, "UpdateChannel")
mockSocketService.AssertNotCalled(t, "EmitEditChannel")
})
t.Run("Unauthorized", func(t *testing.T) {
id := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
mockSocketService := new(mocks.SocketService)
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": fixture.RandStr(8),
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", id)
request, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertNotCalled(t, "Get")
mockGuildService.AssertNotCalled(t, "GetGuild")
mockChannelService.AssertNotCalled(t, "UpdateChannel")
mockSocketService.AssertNotCalled(t, "EmitEditChannel")
})
t.Run("Server Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockError := apperrors.NewInternal()
mockChannelService.On("UpdateChannel", mockChannel).Return(mockError)
mockSocketService := new(mocks.SocketService)
response := mockChannel.SerializeChannel()
mockSocketService.On("EmitEditChannel", mockGuild.ID, &response)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockSocketService.AssertNotCalled(t, "EmitEditChannel")
})
t.Run("Private channel made public", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockChannel.IsPublic = false
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
response := mockChannel.SerializeChannel()
mockChannelService.On("CleanPCMembers", mockChannel.ID).Return(nil)
mockChannelService.On("UpdateChannel", mockChannel).
Run(func(args mock.Arguments) {
mockChannel.IsPublic = true
response = mockChannel.SerializeChannel()
}).
Return(nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitEditChannel", mockGuild.ID, &response)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
"isPublic": true,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(true)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Channel made private", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("AddPrivateChannelMembers", []string{authUser.ID}, mockChannel.ID).Return(nil)
mockChannelService.On("RemovePrivateChannelMembers", []string(nil), mockChannel.ID).Return(nil)
response := mockChannel.SerializeChannel()
mockChannelService.On("UpdateChannel", mockChannel).
Run(func(args mock.Arguments) {
mockChannel.IsPublic = false
response = mockChannel.SerializeChannel()
}).
Return(nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitEditChannel", mockGuild.ID, &response)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(gin.H{
"name": mockChannel.Name,
"isPublic": false,
})
assert.NoError(t, err)
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(true)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
}
func TestHandler_EditChannel_BadRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockUser := fixture.GetMockUser()
router := getAuthenticatedTestRouter(mockUser.ID)
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
testCases := []struct {
name string
body gin.H
}{
{
name: "Name required",
body: gin.H{},
},
{
name: "Name too short",
body: gin.H{
"name": fixture.RandStr(2),
},
},
{
name: "Name too long",
body: gin.H{
"name": fixture.RandStr(31),
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(tc.body)
assert.NoError(t, err)
url := fmt.Sprintf("/api/channels/%s", fixture.RandID())
request, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockGuildService.AssertNotCalled(t, "GetGuild")
mockChannelService.AssertNotCalled(t, "CreateChannel")
})
}
}
func TestHandler_DeleteChannel(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully deleted", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuild.Channels = append(mockGuild.Channels, *mockChannel)
mockGuild.Channels = append(mockGuild.Channels, *fixture.GetMockChannel(mockGuild.ID))
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("DeleteChannel", mockChannel).Return(nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitDeleteChannel", mockChannel)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(true)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Channel not found", func(t *testing.T) {
id := fixture.RandID()
mockError := apperrors.NewNotFound("channel", id)
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", id).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", id)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", id)
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "DeleteChannel")
mockSocketService.AssertNotCalled(t, "EmitDeleteChannel")
})
t.Run("Guild not found", func(t *testing.T) {
mockChannel := fixture.GetMockChannel(fixture.RandID())
mockError := apperrors.NewNotFound("channel", mockChannel.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", *mockChannel.GuildID).Return(nil, mockError)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockGuildService.AssertCalled(t, "GetGuild", *mockChannel.GuildID)
mockGuildService.AssertNotCalled(t, "DeleteChannel")
mockSocketService.AssertNotCalled(t, "EmitDeleteChannel")
})
t.Run("Not the guild owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", *mockChannel.GuildID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
respBody, _ := json.Marshal(gin.H{
"error": e,
})
router.ServeHTTP(rr, request)
assert.Equal(t, e.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockGuildService.AssertCalled(t, "GetGuild", *mockChannel.GuildID)
mockGuildService.AssertNotCalled(t, "DeleteChannel")
mockSocketService.AssertNotCalled(t, "EmitDeleteChannel")
})
t.Run("Channel is last channel of the guild", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuild.Channels = append(mockGuild.Channels, *mockChannel)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", *mockChannel.GuildID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewBadRequest(apperrors.OneChannelRequired)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockGuildService.AssertCalled(t, "GetGuild", *mockChannel.GuildID)
mockGuildService.AssertNotCalled(t, "DeleteChannel")
mockSocketService.AssertNotCalled(t, "EmitDeleteChannel")
})
t.Run("Unauthorized", func(t *testing.T) {
id := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
mockSocketService := new(mocks.SocketService)
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", id)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertNotCalled(t, "Get")
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "DeleteChannel")
mockSocketService.AssertNotCalled(t, "EmitDeleteChannel")
})
t.Run("Server Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuild.Channels = append(mockGuild.Channels, *mockChannel)
mockGuild.Channels = append(mockGuild.Channels, *fixture.GetMockChannel(mockGuild.ID))
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockError := apperrors.NewInternal()
mockChannelService.On("DeleteChannel", mockChannel).Return(mockError)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s", mockChannel.ID)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
}
func TestHandler_CloseDM(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully close", func(t *testing.T) {
channelId := fixture.RandID()
dmId := fixture.RandID()
mockChannelService := new(mocks.ChannelService)
findArgs := mock.Arguments{
authUser.ID,
channelId,
}
mockChannelService.On("GetDMByUserAndChannel", findArgs...).Return(dmId, nil)
setArgs := mock.Arguments{
channelId,
authUser.ID,
false,
}
mockChannelService.On("SetDirectMessageStatus", setArgs...).Return(nil)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s/dm", channelId)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(true)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertExpectations(t)
})
t.Run("DM not found", func(t *testing.T) {
channelId := fixture.RandID()
mockChannelService := new(mocks.ChannelService)
findArgs := mock.Arguments{
authUser.ID,
channelId,
}
mockError := apperrors.NewNotFound("dms", authUser.ID)
mockChannelService.On("GetDMByUserAndChannel", findArgs...).Return("", mockError)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s/dm", channelId)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "GetDMByUserAndChannel", findArgs...)
mockChannelService.AssertNotCalled(t, "SetDirectMessageStatus")
})
t.Run("Unauthorized", func(t *testing.T) {
channelId := fixture.RandID()
mockChannelService := new(mocks.ChannelService)
router := getTestRouter()
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s/dm", channelId)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertNotCalled(t, "GetDMByUserAndChannel")
mockChannelService.AssertNotCalled(t, "SetDirectMessageStatus")
})
t.Run("Server Error", func(t *testing.T) {
channelId := fixture.RandID()
dmId := fixture.RandID()
mockChannelService := new(mocks.ChannelService)
findArgs := mock.Arguments{
authUser.ID,
channelId,
}
mockChannelService.On("GetDMByUserAndChannel", findArgs...).Return(dmId, nil)
setArgs := mock.Arguments{
channelId,
authUser.ID,
false,
}
mockError := apperrors.NewInternal()
mockChannelService.On("SetDirectMessageStatus", setArgs...).Return(mockError)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
})
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
url := fmt.Sprintf("/api/channels/%s/dm", channelId)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(true)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertExpectations(t)
})
}
================================================
FILE: server/handler/friend_handler.go
================================================
package handler
import (
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"log"
"net/http"
)
/*
* FriendHandler contains all routes related to friend actions (/api/account)
*/
// GetUserFriends returns the current users friends
// GetUserFriends godoc
// @Tags Friends
// @Summary Get Current User's Friends
// @Produce json
// @Success 200 {array} model.Friend
// @Failure 404 {object} model.ErrorResponse
// @Router /account/me/friends [get]
func (h *Handler) GetUserFriends(c *gin.Context) {
userId := c.MustGet("userId").(string)
friends, err := h.friendService.GetFriends(userId)
if err != nil {
log.Printf("Unable to find friends for id: %v\n%v", userId, err)
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, friends)
}
// GetUserRequests returns the current users friend requests
// GetUserRequests godoc
// @Tags Friends
// @Summary Get Current User's Friend Requests
// @Produce json
// @Success 200 {array} model.FriendRequest
// @Failure 404 {object} model.ErrorResponse
// @Router /account/me/pending [get]
func (h *Handler) GetUserRequests(c *gin.Context) {
userId := c.MustGet("userId").(string)
requests, err := h.friendService.GetRequests(userId)
if err != nil {
log.Printf("Unable to find requests for id: %v\n%v", userId, err)
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, requests)
}
// SendFriendRequest sends a friend request to the given member param
// SendFriendRequest godoc
// @Tags Friends
// @Summary Send Friend Request
// @Produce json
// @Param memberId path string true "User ID"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /account/{memberId}/friend [post]
func (h *Handler) SendFriendRequest(c *gin.Context) {
userId := c.MustGet("userId").(string)
memberId := c.Param("memberId")
if userId == memberId {
e := apperrors.NewBadRequest(apperrors.AddYourselfError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
authUser, err := h.friendService.GetMemberById(userId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", userId, err)
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
member, err := h.friendService.GetMemberById(memberId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", memberId, err)
e := apperrors.NewNotFound("user", memberId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if they are already friends and no request exists
if !isFriend(authUser, member.ID) && !containsRequest(authUser, member) {
authUser.Requests = append(authUser.Requests, *member)
err = h.friendService.SaveRequests(authUser)
if err != nil {
log.Printf("Unable to add user as friend: %v\n%v", memberId, err)
e := apperrors.NewBadRequest(apperrors.UnableAddError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Emit friends request to the added user
h.socketService.EmitAddFriendRequest(memberId, &model.FriendRequest{
Id: authUser.ID,
Username: authUser.Username,
Image: authUser.Image,
Type: model.Incoming,
})
}
c.JSON(http.StatusOK, true)
}
// RemoveFriend removes the given member param from the current
// users friends.
// RemoveFriend godoc
// @Tags Friends
// @Summary Remove Friend
// @Produce json
// @Param memberId path string true "User ID"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /account/{memberId}/friend [delete]
func (h *Handler) RemoveFriend(c *gin.Context) {
userId := c.MustGet("userId").(string)
memberId := c.Param("memberId")
if userId == memberId {
e := apperrors.NewBadRequest(apperrors.RemoveYourselfError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
authUser, err := h.friendService.GetMemberById(userId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", memberId, err)
e := apperrors.NewNotFound("user", memberId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
member, err := h.friendService.GetMemberById(memberId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", memberId, err)
e := apperrors.NewNotFound("user", memberId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if isFriend(authUser, member.ID) {
err = h.friendService.RemoveFriend(member.ID, authUser.ID)
if err != nil {
log.Printf("Unable to remove user from friends: %v\n%v", memberId, err)
e := apperrors.NewBadRequest(apperrors.UnableRemoveError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Emit signal to remove the person from the friends
h.socketService.EmitRemoveFriend(userId, memberId)
}
c.JSON(http.StatusOK, true)
}
// AcceptFriendRequest accepts the friend request from the given member param
// AcceptFriendRequest godoc
// @Tags Friends
// @Summary Accept Friend's Request
// @Produce json
// @Param memberId path string true "User ID"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /account/{memberId}/friend/accept [post]
func (h *Handler) AcceptFriendRequest(c *gin.Context) {
userId := c.MustGet("userId").(string)
memberId := c.Param("memberId")
if userId == memberId {
e := apperrors.NewBadRequest(apperrors.AcceptYourselfError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
authUser, err := h.friendService.GetMemberById(userId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", userId, err)
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
member, err := h.friendService.GetMemberById(memberId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", memberId, err)
e := apperrors.NewNotFound("user", memberId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if the current user is in the members requests
if containsRequest(member, authUser) {
// Add each other to friends
authUser.Friends = append(authUser.Friends, *member)
member.Friends = append(member.Friends, *authUser)
err = h.friendService.SaveRequests(member)
if err != nil {
log.Printf("Unable to accept friends request from user: %v\n%v", memberId, err)
e := apperrors.NewBadRequest(apperrors.UnableAcceptError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
err = h.friendService.SaveRequests(authUser)
if err != nil {
log.Printf("Unable to accept friends request from user: %v\n%v", memberId, err)
e := apperrors.NewBadRequest(apperrors.UnableAcceptError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
err = h.friendService.DeleteRequest(authUser.ID, member.ID)
if err != nil {
log.Printf("Unable to remove user from friends: %v\n%v", memberId, err)
e := apperrors.NewBadRequest(apperrors.UnableRemoveError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Emit friend information to the accepted person
h.socketService.EmitAddFriend(authUser, member)
}
c.JSON(http.StatusOK, true)
}
// CancelFriendRequest removes the given member param from the current
// users requests.
// CancelFriendRequest godoc
// @Tags Friends
// @Summary Cancel Friend's Request
// @Produce json
// @Param memberId path string true "User ID"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /account/{memberId}/friend/cancel [post]
func (h *Handler) CancelFriendRequest(c *gin.Context) {
userId := c.MustGet("userId").(string)
memberId := c.Param("memberId")
if userId == memberId {
e := apperrors.NewBadRequest(apperrors.CancelYourselfError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
authUser, err := h.friendService.GetMemberById(userId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", memberId, err)
e := apperrors.NewNotFound("user", memberId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
member, err := h.friendService.GetMemberById(memberId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", memberId, err)
e := apperrors.NewNotFound("user", memberId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if the member is in the current user's requests
if containsRequest(authUser, member) || containsRequest(member, authUser) {
err := h.friendService.DeleteRequest(member.ID, authUser.ID)
if err != nil {
log.Printf("Unable to remove user from friends: %v\n%v", memberId, err)
e := apperrors.NewBadRequest(apperrors.UnableRemoveError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
}
c.JSON(http.StatusOK, true)
}
// isFriend checks if the given users are friends
func isFriend(user *model.User, userId string) bool {
for _, v := range user.Friends {
if v.ID == userId {
return true
}
}
return false
}
// containsRequest checks if the given user has a friends request from the current one
func containsRequest(user *model.User, current *model.User) bool {
for _, v := range user.Requests {
if v.ID == current.ID {
return true
}
}
return false
}
================================================
FILE: server/handler/friend_handler_test.go
================================================
package handler
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandler_GetUserFriends(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
friends := make([]model.Friend, 0)
for i := 0; i < 5; i++ {
mockFriend := fixture.GetMockUser()
friends = append(friends, model.Friend{
Id: mockFriend.ID,
Username: mockFriend.Username,
Image: mockFriend.Image,
IsOnline: false,
})
}
t.Run("Successful Fetch", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetFriends", authUser.ID).Return(&friends, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
request, err := http.NewRequest(http.MethodGet, "/api/account/me/friends", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(friends)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
})
t.Run("Unauthorized", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetFriends", authUser.ID).Return(&friends, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
request, err := http.NewRequest(http.MethodGet, "/api/account/me/friends", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockFriendService.AssertNotCalled(t, "GetFriends", authUser.ID, "")
})
t.Run("Error", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetFriends", authUser.ID).Return(nil, fmt.Errorf("some error down call chain"))
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
request, err := http.NewRequest(http.MethodGet, "/api/account/me/friends", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewNotFound("user", authUser.ID)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
})
}
func TestHandler_GetUserRequests(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
requests := make([]model.FriendRequest, 0)
for i := 0; i < 5; i++ {
mockRequest := fixture.GetMockUser()
requests = append(requests, model.FriendRequest{
Id: mockRequest.ID,
Username: mockRequest.Username,
Image: mockRequest.Image,
Type: 0,
})
}
t.Run("Successful Fetch", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetRequests", authUser.ID).Return(&requests, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
request, err := http.NewRequest(http.MethodGet, "/api/account/me/pending", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(requests)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
})
t.Run("Unauthorized", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetRequests", authUser.ID).Return(&requests, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
request, err := http.NewRequest(http.MethodGet, "/api/account/me/pending", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockFriendService.AssertNotCalled(t, "GetRequests", authUser.ID, "")
})
t.Run("Error", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetRequests", authUser.ID).Return(nil, fmt.Errorf("some error down call chain"))
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
request, err := http.NewRequest(http.MethodGet, "/api/account/me/pending", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewNotFound("user", authUser.ID)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
})
}
func TestHandler_AcceptFriendRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
current := fixture.GetMockUser()
t.Run("Successfully accepted request", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockUser.Requests = append(mockUser.Requests, *current)
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockFriendService.On("SaveRequests", current).
Run(func(args mock.Arguments) {
current.Friends = append(current.Friends, *mockUser)
}).
Return(nil)
mockFriendService.On("SaveRequests", mockUser).
Run(func(args mock.Arguments) {
mockUser.Friends = append(mockUser.Friends, *current)
}).
Return(nil)
mockFriendService.On("DeleteRequest", current.ID, mockUser.ID).Return(nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitAddFriend", current, mockUser).Return()
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend/accept", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
})
t.Run("Member does not contain a request", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend/accept", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockFriendService.AssertNotCalled(t, "SaveRequests")
mockSocketService.AssertNotCalled(t, "EmitAddFriendRequest")
})
t.Run("Unauthorized", func(t *testing.T) {
id := fixture.RandID()
mockUserService := new(mocks.UserService)
mockUserService.On("GetMemberById", id).Return(nil, nil)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
url := fmt.Sprintf("/api/account/%s/friend/accept", id)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockUserService.AssertNotCalled(t, "GetMemberById", id)
})
t.Run("NotFound", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockError := apperrors.NewNotFound("user", mockUser.ID)
mockFriendService.On("GetMemberById", mockUser.ID).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend/accept", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockFriendService.AssertNotCalled(t, "SaveRequests")
mockSocketService.AssertNotCalled(t, "EmitAddFriendRequest")
})
t.Run("MemberId and UserId are the same", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend/accept", current.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.AcceptYourselfError)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertNotCalled(t, "GetMemberById")
mockFriendService.AssertNotCalled(t, "SaveRequests")
mockFriendService.AssertNotCalled(t, "DeleteRequest")
mockSocketService.AssertNotCalled(t, "EmitAddFriend")
})
t.Run("Error", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockUser.Requests = append(mockUser.Requests, *current)
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockError := apperrors.NewBadRequest(apperrors.UnableAcceptError)
mockFriendService.On("SaveRequests", mockUser).
Return(mockError)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend/accept", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockSocketService.AssertNotCalled(t, "EmitAddFriend")
})
}
func TestHandler_SendFriendRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
current := fixture.GetMockUser()
t.Run("Successfully send request", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockFriendService.On("SaveRequests", current).
Run(func(args mock.Arguments) {
current.Requests = append(current.Requests, *mockUser)
}).
Return(nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitAddFriendRequest", mockUser.ID, &model.FriendRequest{
Id: current.ID,
Username: current.Username,
Image: current.Image,
Type: 1,
}).Return()
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
})
t.Run("Member already is a friend", func(t *testing.T) {
mockUser := fixture.GetMockUser()
current.Friends = append(current.Friends, *mockUser)
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockFriendService.AssertNotCalled(t, "SaveRequests")
mockSocketService.AssertNotCalled(t, "EmitAddFriendRequest")
})
t.Run("Member already contains request", func(t *testing.T) {
mockUser := fixture.GetMockUser()
current.Requests = append(current.Requests, *mockUser)
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockFriendService.AssertNotCalled(t, "SaveRequests")
mockSocketService.AssertNotCalled(t, "EmitAddFriendRequest")
})
t.Run("Unauthorized", func(t *testing.T) {
id := fixture.RandID()
mockUserService := new(mocks.UserService)
mockUserService.On("GetMemberById", id).Return(nil, nil)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
url := fmt.Sprintf("/api/account/%s/friend", id)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockUserService.AssertNotCalled(t, "GetMemberById", id)
})
t.Run("NotFound", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockError := apperrors.NewNotFound("user", mockUser.ID)
mockFriendService.On("GetMemberById", mockUser.ID).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockFriendService.AssertNotCalled(t, "SaveRequests")
mockSocketService.AssertNotCalled(t, "EmitAddFriendRequest")
})
t.Run("MemberId and UserId are the same", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", current.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.AddYourselfError)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertNotCalled(t, "GetMemberById")
mockFriendService.AssertNotCalled(t, "SaveRequests")
mockSocketService.AssertNotCalled(t, "EmitAddFriendRequest")
})
t.Run("Error", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockError := apperrors.NewBadRequest(apperrors.UnableAddError)
mockFriendService.On("SaveRequests", current).
Return(mockError)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockSocketService.AssertNotCalled(t, "EmitAddFriendRequest")
})
}
func TestHandler_RemoveFriend(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
current := fixture.GetMockUser()
t.Run("Successfully removed friend", func(t *testing.T) {
mockUser := fixture.GetMockUser()
current.Friends = append(current.Friends, *mockUser)
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockFriendService.On("RemoveFriend", mockUser.ID, current.ID).
Run(func(args mock.Arguments) {
current.Friends = make([]model.User, 0)
}).
Return(nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitRemoveFriend", current.ID, mockUser.ID).Return()
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
})
t.Run("Member was not a friend", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockFriendService.AssertNotCalled(t, "RemoveFriend")
mockSocketService.AssertNotCalled(t, "EmitRemoveFriend")
})
t.Run("Unauthorized", func(t *testing.T) {
id := fixture.RandID()
mockUserService := new(mocks.UserService)
mockUserService.On("GetMemberById", id).Return(nil, nil)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
url := fmt.Sprintf("/api/account/%s/friend", id)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockUserService.AssertNotCalled(t, "GetMemberById", id)
})
t.Run("NotFound", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockError := apperrors.NewNotFound("user", mockUser.ID)
mockFriendService.On("GetMemberById", mockUser.ID).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockFriendService.AssertNotCalled(t, "RemoveFriend")
mockSocketService.AssertNotCalled(t, "EmitRemoveFriend")
})
t.Run("MemberId and UserId are the same", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", current.ID)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.RemoveYourselfError)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertNotCalled(t, "GetMemberById")
mockFriendService.AssertNotCalled(t, "RemoveFriend")
mockSocketService.AssertNotCalled(t, "EmitRemoveFriend")
})
t.Run("Error", func(t *testing.T) {
mockUser := fixture.GetMockUser()
current.Friends = append(current.Friends, *mockUser)
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockError := apperrors.NewBadRequest(apperrors.UnableRemoveError)
mockFriendService.On("RemoveFriend", mockUser.ID, current.ID).
Return(mockError)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
SocketService: mockSocketService,
})
url := fmt.Sprintf("/api/account/%s/friend", mockUser.ID)
request, err := http.NewRequest(http.MethodDelete, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockSocketService.AssertNotCalled(t, "EmitRemoveFriend")
})
}
func TestHandler_CancelFriendRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
current := fixture.GetMockUser()
t.Run("Successfully canceled request", func(t *testing.T) {
mockUser := fixture.GetMockUser()
current.Requests = append(current.Requests, *mockUser)
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockFriendService.On("DeleteRequest", mockUser.ID, current.ID).
Run(func(args mock.Arguments) {
current.Requests = make([]model.User, 0)
}).
Return(nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
url := fmt.Sprintf("/api/account/%s/friend/cancel", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
})
t.Run("Current does not contain a request", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
url := fmt.Sprintf("/api/account/%s/friend/cancel", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockFriendService.AssertNotCalled(t, "DeleteRequest")
})
t.Run("Unauthorized", func(t *testing.T) {
id := fixture.RandID()
mockUserService := new(mocks.UserService)
mockUserService.On("GetMemberById", id).Return(nil, nil)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
UserService: mockUserService,
})
url := fmt.Sprintf("/api/account/%s/friend/cancel", id)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockUserService.AssertNotCalled(t, "GetMemberById", id)
})
t.Run("NotFound", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockError := apperrors.NewNotFound("user", mockUser.ID)
mockFriendService.On("GetMemberById", mockUser.ID).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
url := fmt.Sprintf("/api/account/%s/friend/cancel", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
mockFriendService.AssertNotCalled(t, "DeleteRequest")
})
t.Run("MemberId and UserId are the same", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
url := fmt.Sprintf("/api/account/%s/friend/cancel", current.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.CancelYourselfError)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertNotCalled(t, "GetMemberById")
mockFriendService.AssertNotCalled(t, "DeleteRequest")
})
t.Run("Error", func(t *testing.T) {
mockUser := fixture.GetMockUser()
current.Requests = append(current.Requests, *mockUser)
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetMemberById", current.ID).Return(current, nil)
mockFriendService.On("GetMemberById", mockUser.ID).Return(mockUser, nil)
mockError := apperrors.NewBadRequest(apperrors.UnableRemoveError)
mockFriendService.On("DeleteRequest", mockUser.ID, current.ID).
Return(mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(current.ID)
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
url := fmt.Sprintf("/api/account/%s/friend/cancel", mockUser.ID)
request, err := http.NewRequest(http.MethodPost, url, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockFriendService.AssertExpectations(t)
})
}
================================================
FILE: server/handler/guild_handler.go
================================================
package handler
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/lib/pq"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"log"
"mime/multipart"
"net/http"
"os"
"strconv"
"strings"
)
/*
* GuildHandler contains all routes related to guild actions (/api/guilds)
*/
// GetUserGuilds returns the current users guilds
// GetUserGuilds godoc
// @Tags Guilds
// @Summary Get Current User's Guilds
// @Produce json
// @Success 200 {array} model.GuildResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /guilds [get]
func (h *Handler) GetUserGuilds(c *gin.Context) {
userId := c.MustGet("userId").(string)
guilds, err := h.guildService.GetUserGuilds(userId)
if err != nil {
log.Printf("Unable to find guilds for id: %v\n%v", userId, err)
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, guilds)
}
// GetGuildMembers returns the given guild's members
// GetGuildMembers godoc
// @Tags Guilds
// @Summary Get Guild Members
// @Produce json
// @Param guildId path string true "Guild ID"
// @Success 200 {array} model.MemberResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /guilds/{guildId}/members [get]
func (h *Handler) GetGuildMembers(c *gin.Context) {
userId := c.MustGet("userId").(string)
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
log.Printf("Unable to find guilds for id: %v\n%v", guildId, err)
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if a member
if !isMember(guild, userId) {
e := apperrors.NewAuthorization(apperrors.NotAMember)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
members, err := h.guildService.GetGuildMembers(userId, guildId)
if err != nil {
log.Printf("Unable to find guilds for id: %v\n%v", userId, err)
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, members)
}
// GetVCMembers returns the given guild's members that are currently in the VC
// GetVCMembers godoc
// @Tags Guilds
// @Summary Get Guild VC Members
// @Produce json
// @Param guildId path string true "Guild ID"
// @Success 200 {array} model.VCMemberResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /guilds/{guildId}/vcmembers [get]
func (h *Handler) GetVCMembers(c *gin.Context) {
userId := c.MustGet("userId").(string)
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
log.Printf("Unable to find guilds for id: %v\n%v", guildId, err)
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if a member
if !isMember(guild, userId) {
e := apperrors.NewAuthorization(apperrors.NotAMember)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
members, err := h.guildService.GetVCMembers(guild.ID)
if err != nil {
log.Printf("Unable to find vc members for id: %v\n%v", guildId, err)
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, members)
}
type createGuildRequest struct {
// Guild Name. 3 to 30 characters
Name string `json:"name"`
} //@name CreateGuildRequest
func (r createGuildRequest) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Name, validation.Required, validation.Length(3, 30)),
)
}
func (r *createGuildRequest) sanitize() {
r.Name = strings.TrimSpace(r.Name)
}
// CreateGuild creates a guild
// CreateGuild godoc
// @Tags Guilds
// @Summary Create Guild
// @Accepts json
// @Produce json
// @Param request body createGuildRequest true "Create Guild"
// @Success 201 {array} model.GuildResponse
// @Failure 400 {object} model.ErrorsResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/create [post]
func (h *Handler) CreateGuild(c *gin.Context) {
var req createGuildRequest
// Bind incoming json to struct and check for validation errors
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
userId := c.MustGet("userId").(string)
authUser, err := h.guildService.GetUser(userId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", userId, err)
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if the user is already in 100 guilds
if len(authUser.Guilds) >= model.MaximumGuilds {
e := apperrors.NewBadRequest(apperrors.GuildLimitReached)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
guildParams := model.Guild{
Name: req.Name,
OwnerId: userId,
}
// Add the current user as a member
guildParams.Members = append(guildParams.Members, *authUser)
guild, err := h.guildService.CreateGuild(&guildParams)
if err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Create the default 'general' channel for the guild
channelParams := model.Channel{
GuildID: &guild.ID,
Name: "general",
IsPublic: true,
}
channel, err := h.channelService.CreateChannel(&channelParams)
if err != nil {
log.Printf("Failed to create channel for guild: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
c.JSON(http.StatusCreated, guild.SerializeGuild(channel.ID))
}
// editGuildRequest specifies the form to edit the guild.
// If Image is not nil then the guild's icon got changed.
// If Icon is not nil then the guild kept its old one.
// If both are nil then the icon got reset.
type editGuildRequest struct {
// Guild Name. 3 to 30 characters
Name string `form:"name"`
// image/png or image/jpeg
Image *multipart.FileHeader `form:"image" swaggertype:"string" format:"binary"`
// The old guild icon url if no new image is selected. Set to null to reset the guild icon
Icon *string `form:"icon"`
} //@name EditGuildRequest
func (r editGuildRequest) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Name, validation.Required, validation.Length(3, 30)),
)
}
func (r *editGuildRequest) sanitize() {
r.Name = strings.TrimSpace(r.Name)
}
// EditGuild edits the given guild
// EditGuild godoc
// @Tags Guilds
// @Summary Edit Guild
// @Accepts mpfd
// @Produce json
// @Param request body editGuildRequest true "Edit Guild"
// @Param guildId path string true "Guild ID"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorsResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/{guildId} [put]
func (h *Handler) EditGuild(c *gin.Context) {
var req editGuildRequest
// Bind incoming json to struct and check for validation errors
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
userId := c.MustGet("userId").(string)
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
guild.Name = req.Name
// Guild icon got changed
if req.Image != nil {
// Validate image mime-type is allowable
mimeType := req.Image.Header.Get("Content-Type")
if valid := isAllowedImageType(mimeType); !valid {
toFieldErrorResponse(c, "Image", apperrors.InvalidImageType)
return
}
directory := fmt.Sprintf("valkyrie/guilds/%s", guild.ID)
url, err := h.userService.ChangeAvatar(req.Image, directory)
if err != nil {
e := apperrors.NewInternal()
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if guild.Icon != nil {
_ = h.userService.DeleteImage(*guild.Icon)
}
guild.Icon = &url
// Guild kept its old icon
} else if req.Icon != nil {
guild.Icon = req.Icon
// Guild reset its icon
} else {
guild.Icon = nil
}
if err = h.guildService.UpdateGuild(guild); err != nil {
log.Printf("Failed to update guild: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Emit guild changes to guild members
h.socketService.EmitEditGuild(guild)
c.JSON(http.StatusOK, true)
}
// GetInvite creates an invitation for the given guild
// The isPermanent query parameter specifies if the invite
// should not be deleted after it got used
// GetInvite godoc
// @Tags Guilds
// @Summary Get Guild Invite
// @Produce json
// @Param guildId path string true "Guild ID"
// @Param isPermanent query boolean false "Is Permanent"
// @Success 200 string link
// @Failure 400 {object} model.ErrorResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/{guildId}/invite [get]
func (h *Handler) GetInvite(c *gin.Context) {
guildId := c.Param("guildId")
permanent := c.Query("isPermanent")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
userId := c.MustGet("userId").(string)
// Must be a member to create an invitation
if !isMember(guild, userId) {
e := apperrors.NewAuthorization(apperrors.MustBeMemberInvite)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
isPermanent := false
if permanent != "" {
isPermanent, err = strconv.ParseBool(permanent)
if err != nil {
e := apperrors.NewBadRequest(apperrors.IsPermanentError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
}
ctx := context.Background()
link, err := h.guildService.GenerateInviteLink(ctx, guild.ID, isPermanent)
if err != nil {
e := apperrors.NewInternal()
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if isPermanent {
guild.InviteLinks = append(guild.InviteLinks, link)
_ = h.guildService.UpdateGuild(guild)
}
origin := os.Getenv("CORS_ORIGIN")
c.JSON(http.StatusOK, fmt.Sprintf("%s/%s", origin, link))
}
// DeleteGuildInvites removes all permanent invites from the given guild
// DeleteGuildInvites godoc
// @Tags Guilds
// @Summary Delete all permanent invite links
// @Produce json
// @Param guildId path string true "Guild ID"
// @Success 200 {object} model.Success
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/{guildId}/invite [delete]
func (h *Handler) DeleteGuildInvites(c *gin.Context) {
userId := c.MustGet("userId").(string)
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.InvalidateInvitesError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
ctx := context.Background()
h.guildService.InvalidateInvites(ctx, guild)
guild.InviteLinks = make(pq.StringArray, 0)
if err = h.guildService.UpdateGuild(guild); err != nil {
log.Printf("Failed to delete guild invites: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
c.JSON(http.StatusOK, true)
}
type joinReq struct {
Link string `json:"link"`
} //@name JoinRequest
func (r joinReq) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Link, validation.Required),
)
}
func (r *joinReq) sanitize() {
r.Link = strings.TrimSpace(r.Link)
}
// JoinGuild adds the current user to invited guild
// JoinGuild godoc
// @Tags Guilds
// @Summary Join Guild
// @Produce json
// @Param request body joinReq true "Join Guild"
// @Success 200 {object} model.GuildResponse
// @Failure 400 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/join [post]
func (h *Handler) JoinGuild(c *gin.Context) {
var req joinReq
// Bind incoming json to struct and check for validation errors
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
userId := c.MustGet("userId").(string)
authUser, err := h.guildService.GetUser(userId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", userId, err)
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if the user has reached the guild limit
if len(authUser.Guilds) >= model.MaximumGuilds {
e := apperrors.NewBadRequest(apperrors.GuildLimitReached)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// If the link contains the domain, remove it
if strings.Contains(req.Link, "/") {
req.Link = req.Link[strings.LastIndex(req.Link, "/")+1:]
}
ctx := context.Background()
guildId, err := h.guildService.GetGuildIdFromInvite(ctx, req.Link)
if err != nil {
e := apperrors.NewBadRequest(apperrors.InvalidInviteError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewBadRequest(apperrors.InvalidInviteError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if the user is banned from the guild
if isBanned(guild, authUser.ID) {
e := apperrors.NewBadRequest(apperrors.BannedFromServer)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if the user is already a member
if isMember(guild, authUser.ID) {
e := apperrors.NewBadRequest(apperrors.AlreadyMember)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
guild.Members = append(guild.Members, *authUser)
if err = h.guildService.UpdateGuild(guild); err != nil {
log.Printf("Failed to join guild: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Emit new member to the guild
h.socketService.EmitAddMember(guild.ID, authUser)
channel, _ := h.guildService.GetDefaultChannel(guildId)
c.JSON(http.StatusCreated, guild.SerializeGuild(channel.ID))
}
// LeaveGuild leaves the given guild
// LeaveGuild godoc
// @Tags Guilds
// @Summary Leave Guild
// @Produce json
// @Param guildId path string true "Guild ID"
// @Success 200 {object} model.Success
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/{guildId} [delete]
func (h *Handler) LeaveGuild(c *gin.Context) {
userId := c.MustGet("userId").(string)
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if guild.OwnerId == userId {
e := apperrors.NewAuthorization(apperrors.OwnerCantLeave)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if err := h.guildService.RemoveMember(userId, guildId); err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Emit signal to remove the member from the guild
h.socketService.EmitRemoveMember(guild.ID, userId)
c.JSON(http.StatusOK, true)
}
// DeleteGuild deletes the given guild
// DeleteGuild godoc
// @Tags Guilds
// @Summary Delete Guild
// @Produce json
// @Param guildId path string true "Guild ID"
// @Success 200 {object} model.Success
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/{guildId}/delete [delete]
func (h *Handler) DeleteGuild(c *gin.Context) {
userId := c.MustGet("userId").(string)
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.DeleteGuildError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Get the ID of all members to emit the deletion to
members := make([]string, 0)
for _, member := range guild.Members {
members = append(members, member.ID)
}
if err := h.guildService.DeleteGuild(guildId); err != nil {
log.Printf("Failed to leave guild: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Emit signal to remove the guild to its members
h.socketService.EmitDeleteGuild(guildId, members)
c.JSON(http.StatusOK, true)
}
// isMember checks if the given user is a member of the guild
func isMember(guild *model.Guild, userId string) bool {
for _, v := range guild.Members {
if v.ID == userId {
return true
}
}
return false
}
// isBanned checks if the given user is banned from the guild
func isBanned(guild *model.Guild, userId string) bool {
for _, v := range guild.Bans {
if v.ID == userId {
return true
}
}
return false
}
================================================
FILE: server/handler/guild_handler_test.go
================================================
package handler
import (
"bytes"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
)
func TestHandler_GetUserGuilds(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
response := make([]model.GuildResponse, 0)
for i := 0; i < 5; i++ {
mockGuild := fixture.GetMockGuild("")
response = append(response, mockGuild.SerializeGuild(fixture.RandID()))
}
t.Run("Successful Fetch", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUserGuilds", authUser.ID).Return(&response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
request, err := http.NewRequest(http.MethodGet, "/api/guilds", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(response)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Unauthorized", func(t *testing.T) {
mockFriendService := new(mocks.FriendService)
mockFriendService.On("GetUserGuilds", authUser.ID).Return(&response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
FriendService: mockFriendService,
})
request, err := http.NewRequest(http.MethodGet, "/api/guilds", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockFriendService.AssertNotCalled(t, "GetUserGuilds", authUser.ID, "")
})
t.Run("Error", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUserGuilds", authUser.ID).Return(nil, fmt.Errorf("some error down call chain"))
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
request, err := http.NewRequest(http.MethodGet, "/api/guilds", nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewNotFound("user", authUser.ID)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
}
func TestHandler_GetGuildMembers(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
guild := fixture.GetMockGuild("")
guild.Members = append(guild.Members, *authUser)
response := make([]model.MemberResponse, 0)
for i := 0; i < 5; i++ {
mockUser := fixture.GetMockUser()
response = append(response, model.MemberResponse{
Id: mockUser.ID,
Username: mockUser.Username,
Image: mockUser.Image,
IsOnline: mockUser.IsOnline,
CreatedAt: mockUser.CreatedAt,
UpdatedAt: mockUser.UpdatedAt,
IsFriend: false,
})
}
t.Run("Successful Fetch", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", guild.ID).Return(guild, nil)
mockGuildService.On("GetGuildMembers", authUser.ID, guild.ID).Return(&response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/members", guild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(response)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", guild.ID).Return(guild, nil)
mockGuildService.On("GetGuildMembers", authUser.ID, guild.ID).Return(&response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/members", guild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockGuildService.AssertNotCalled(t, "GetGuild", guild.ID)
mockGuildService.AssertNotCalled(t, "GetGuildMembers", authUser.ID, guild.ID)
})
t.Run("Not a member of the guild", func(t *testing.T) {
invalidGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", invalidGuild.ID).Return(invalidGuild, nil)
mockGuildService.On("GetGuildMembers", authUser.ID, invalidGuild.ID).Return(&response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/members", invalidGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
mockError := apperrors.NewAuthorization(apperrors.NotAMember)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", invalidGuild.ID)
mockGuildService.AssertNotCalled(t, "GetGuildMembers", authUser.ID, invalidGuild.ID)
})
t.Run("Guild not found", func(t *testing.T) {
invalidGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", invalidGuild.ID)
mockGuildService.On("GetGuild", invalidGuild.ID).Return(nil, mockError)
mockGuildService.On("GetGuildMembers", authUser.ID, invalidGuild.ID).Return(&response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/members", invalidGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", invalidGuild.ID)
mockGuildService.AssertNotCalled(t, "GetGuildMembers", authUser.ID, invalidGuild.ID)
})
t.Run("Error", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", guild.ID).Return(guild, nil)
mockError := apperrors.NewNotFound("user", authUser.ID)
mockGuildService.On("GetGuildMembers", authUser.ID, guild.ID).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/members", guild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", guild.ID)
mockGuildService.AssertCalled(t, "GetGuildMembers", authUser.ID, guild.ID)
})
}
func TestHandler_GetVCMembers(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
guild := fixture.GetMockGuild("")
guild.Members = append(guild.Members, *authUser)
response := make([]model.VCMemberResponse, 0)
for i := 0; i < 5; i++ {
mockUser := fixture.GetMockUser()
guild.VCMembers = append(guild.VCMembers, *authUser)
response = append(response, model.VCMemberResponse{
Id: mockUser.ID,
Username: mockUser.Username,
Image: mockUser.Image,
IsMuted: false,
IsDeafened: false,
})
}
t.Run("Successful Fetch", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", guild.ID).Return(guild, nil)
mockGuildService.On("GetVCMembers", guild.ID).Return(&response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/vcmembers", guild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(response)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", guild.ID).Return(guild, nil)
mockGuildService.On("GetVCMembers", guild.ID).Return(response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/vcmembers", guild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockGuildService.AssertNotCalled(t, "GetGuild", guild.ID)
mockGuildService.AssertNotCalled(t, "GetVCMembers", guild.ID)
})
t.Run("Not a member of the guild", func(t *testing.T) {
invalidGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", invalidGuild.ID).Return(invalidGuild, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/vcmembers", invalidGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
mockError := apperrors.NewAuthorization(apperrors.NotAMember)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", invalidGuild.ID)
mockGuildService.AssertNotCalled(t, "GetVCMembers", guild.ID)
})
t.Run("Guild not found", func(t *testing.T) {
invalidGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", invalidGuild.ID)
mockGuildService.On("GetGuild", invalidGuild.ID).Return(nil, mockError)
mockGuildService.On("GetVCMembers", guild.ID).Return(&response, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/vcmembers", invalidGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", invalidGuild.ID)
mockGuildService.AssertNotCalled(t, "GetVCMembers", guild.ID)
})
t.Run("Error", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", guild.ID).Return(guild, nil)
mockError := apperrors.NewNotFound("guild", guild.ID)
mockGuildService.On("GetVCMembers", guild.ID).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/vcmembers", guild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", guild.ID)
mockGuildService.AssertCalled(t, "GetVCMembers", guild.ID)
})
}
func TestHandler_CreateGuild(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successful guild creation", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
router := getAuthenticatedTestRouter(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUser", authUser.ID).Return(authUser, nil)
reqBody, err := json.Marshal(gin.H{
"name": mockGuild.Name,
})
assert.NoError(t, err)
guildParams := &model.Guild{
Name: mockGuild.Name,
OwnerId: authUser.ID,
}
guildParams.Members = append(guildParams.Members, *authUser)
mockGuildService.On("CreateGuild", guildParams).Return(mockGuild, nil)
defaultChannel := fixture.GetMockChannel(mockGuild.ID)
defaultChannel.Name = "general"
mockGuild.Channels = append(mockGuild.Channels, *defaultChannel)
mockChannelService := new(mocks.ChannelService)
channelParams := &model.Channel{
GuildID: &mockGuild.ID,
Name: defaultChannel.Name,
IsPublic: true,
}
mockChannelService.On("CreateChannel", channelParams).Return(defaultChannel, nil)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/guilds/create", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(mockGuild.SerializeGuild(defaultChannel.ID))
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusCreated, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetUser", authUser.ID)
mockGuildService.AssertCalled(t, "CreateGuild", guildParams)
mockChannelService.AssertCalled(t, "CreateChannel", channelParams)
})
t.Run("Error Returned from GuildService.CreateGuild", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
router := getAuthenticatedTestRouter(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUser", authUser.ID).Return(authUser, nil)
reqBody, err := json.Marshal(gin.H{
"name": mockGuild.Name,
})
assert.NoError(t, err)
guildParams := &model.Guild{
Name: mockGuild.Name,
OwnerId: authUser.ID,
}
guildParams.Members = append(guildParams.Members, *authUser)
mockError := apperrors.NewInternal()
mockGuildService.On("CreateGuild", guildParams).Return(nil, mockError)
mockChannelService := new(mocks.ChannelService)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/guilds/create", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetUser", authUser.ID)
mockGuildService.AssertCalled(t, "CreateGuild", guildParams)
mockChannelService.AssertNotCalled(t, "CreateChannel")
})
t.Run("User already is in the maximum number of guilds", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
for i := 0; i < model.MaximumGuilds; i++ {
guild := fixture.GetMockGuild("")
authUser.Guilds = append(authUser.Guilds, *guild)
}
router := getAuthenticatedTestRouter(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUser", authUser.ID).Return(authUser, nil)
reqBody, err := json.Marshal(gin.H{
"name": mockGuild.Name,
})
assert.NoError(t, err)
mockError := apperrors.NewBadRequest(apperrors.GuildLimitReached)
mockChannelService := new(mocks.ChannelService)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/guilds/create", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetUser", authUser.ID)
mockGuildService.AssertNotCalled(t, "CreateGuild")
mockChannelService.AssertNotCalled(t, "CreateChannel")
})
t.Run("Unauthorized", func(t *testing.T) {
router := getTestRouter()
mockGuildService := new(mocks.GuildService)
mockChannelService := new(mocks.ChannelService)
reqBody, err := json.Marshal(gin.H{
"name": fixture.RandStringRunes(6),
})
assert.NoError(t, err)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
ChannelService: mockChannelService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPost, "/api/guilds/create", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "CreateGuild")
mockChannelService.AssertNotCalled(t, "CreateChannel")
})
}
func TestHandler_CreateGuild_BadRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockUser := fixture.GetMockUser()
router := getAuthenticatedTestRouter(mockUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUser", mockUser.ID).Return(mockUser, nil)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
testCases := []struct {
name string
body gin.H
}{
{
name: "Name required",
body: gin.H{},
},
{
name: "Name too short",
body: gin.H{
"name": fixture.RandStr(2),
},
},
{
name: "Name too long",
body: gin.H{
"name": fixture.RandStr(31),
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(tc.body)
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/guilds/create", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockGuildService.AssertNotCalled(t, "CreateGuild")
})
}
}
func TestHandler_UpdateGuild(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully updated guild", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
router := getAuthenticatedTestRouter(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
name := fixture.RandStringRunes(8)
form := url.Values{}
form.Add("name", name)
mockGuildService.On("UpdateGuild", mockGuild).
Run(func(args mock.Arguments) {
mockGuild.Name = name
}).
Return(nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitEditGuild", mockGuild).Return()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodPut, "/api/guilds/"+mockGuild.ID, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertCalled(t, "UpdateGuild", mockGuild)
mockSocketService.AssertCalled(t, "EmitEditGuild", mockGuild)
})
t.Run("Error Returned from GuildService.UpdateGuild", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
router := getAuthenticatedTestRouter(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
name := fixture.RandStringRunes(8)
form := url.Values{}
form.Add("name", name)
mockError := apperrors.NewInternal()
mockGuildService.On("UpdateGuild", mockGuild).Return(mockError)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitEditGuild", mockGuild).Return()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodPut, "/api/guilds/"+mockGuild.ID, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertCalled(t, "UpdateGuild", mockGuild)
mockSocketService.AssertNotCalled(t, "EmitEditGuild", mockGuild)
})
t.Run("Not the owner of the guild", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
router := getAuthenticatedTestRouter(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
name := fixture.RandStringRunes(8)
form := url.Values{}
form.Add("name", name)
mockSocketService := new(mocks.SocketService)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodPut, "/api/guilds/"+mockGuild.ID, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
mockError := apperrors.NewAuthorization(apperrors.MustBeOwner)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "UpdateGuild", mockGuild)
mockSocketService.AssertNotCalled(t, "EmitEditGuild", mockGuild)
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
router := getTestRouter()
mockGuildService := new(mocks.GuildService)
name := fixture.RandStringRunes(8)
form := url.Values{}
form.Add("name", name)
mockSocketService := new(mocks.SocketService)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodPut, "/api/guilds/"+mockGuild.ID, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "UpdateGuild", mockGuild)
mockSocketService.AssertNotCalled(t, "EmitEditGuild", mockGuild)
})
t.Run("Guild not found", func(t *testing.T) {
id := fixture.RandID()
router := getAuthenticatedTestRouter(authUser.ID)
mockError := apperrors.NewNotFound("guild", id)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", id).Return(nil, mockError)
name := fixture.RandStringRunes(8)
form := url.Values{}
form.Add("name", name)
mockSocketService := new(mocks.SocketService)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
request, err := http.NewRequest(http.MethodPut, "/api/guilds/"+id, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", id)
mockGuildService.AssertNotCalled(t, "UpdateGuild", mock.AnythingOfType("*model.Guild"))
mockSocketService.AssertNotCalled(t, "EmitEditGuild", mock.AnythingOfType("*model.Guild"))
})
}
func TestHandler_UpdateGuild_BadRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockUser := fixture.GetMockUser()
router := getAuthenticatedTestRouter(mockUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUser", mockUser.ID).Return(mockUser, nil)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
testCases := []struct {
name string
body url.Values
}{
{
name: "Name required",
body: map[string][]string{},
},
{
name: "Name too short",
body: map[string][]string{
"name": {fixture.RandStr(2)},
},
},
{
name: "Name too long",
body: map[string][]string{
"name": {fixture.RandStr(31)},
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
reqUrl := fmt.Sprintf("/api/guilds/%s", fixture.RandID())
form := tc.body
request, _ := http.NewRequest(http.MethodPut, reqUrl, strings.NewReader(form.Encode()))
request.Form = form
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockGuildService.AssertNotCalled(t, "GetGuild")
})
}
}
func TestHandler_GetInvite(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
origin := "http://localhost:3000"
err := os.Setenv("CORS_ORIGIN", origin)
assert.NoError(t, err)
t.Run("Successful Fetch", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuild.Members = append(mockGuild.Members, *authUser)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
getInviteArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
mockGuild.ID,
false,
}
link := fixture.RandID()
mockGuildService.On("GenerateInviteLink", getInviteArgs...).Return(link, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(fmt.Sprintf("%s/%s", origin, link))
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
})
t.Run("Not a member of the guild", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.MustBeMemberInvite)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "GenerateInviteLink")
})
t.Run("Guild not found", func(t *testing.T) {
id := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", id)
mockGuildService.On("GetGuild", id).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", id)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", id)
mockGuildService.AssertNotCalled(t, "GenerateInviteLink")
})
t.Run("Invalid isPermanent value", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuild.Members = append(mockGuild.Members, *authUser)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite?isPermanent=yes", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.IsPermanentError)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "GenerateInviteLink")
})
t.Run("Invite isPermanent success", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuild.Members = append(mockGuild.Members, *authUser)
link := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("UpdateGuild", mockGuild).
Run(func(args mock.Arguments) {
mockGuild.InviteLinks = append(mockGuild.InviteLinks, link)
}).
Return(nil)
getInviteArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
mockGuild.ID,
true,
}
mockGuildService.On("GenerateInviteLink", getInviteArgs...).Return(link, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite?isPermanent=true", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(fmt.Sprintf("%s/%s", origin, link))
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuild.Members = append(mockGuild.Members, *authUser)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
getInviteArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
mockGuild.ID,
false,
}
mockError := apperrors.NewInternal()
mockGuildService.On("GenerateInviteLink", getInviteArgs...).Return("", mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
}
func TestHandler_DeleteGuildInvites(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully deleted", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
mockGuild,
}
mockGuildService.On("InvalidateInvites", mockArgs...).Return()
mockGuildService.On("UpdateGuild", mockGuild).Return(nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
})
t.Run("Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
mockGuild,
}
mockGuildService.On("InvalidateInvites", mockArgs...).Return()
mockError := apperrors.NewInternal()
mockGuildService.On("UpdateGuild", mockGuild).Return(mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Not the server owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidateInvitesError)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "InvalidateInvites")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
})
t.Run("Guild not found", func(t *testing.T) {
id := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", id)
mockGuildService.On("GetGuild", id).Return(nil, mockError)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/invite", id)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", id)
mockGuildService.AssertNotCalled(t, "InvalidateInvites")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
})
}
func TestHandler_JoinGuild(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully joined", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
link := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUser", authUser.ID).Return(authUser, nil)
mockArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
link,
}
mockGuildService.On("GetGuildIdFromInvite", mockArgs...).Return(mockGuild.ID, nil)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("UpdateGuild", mockGuild).Return(nil)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildService.On("GetDefaultChannel", mockGuild.ID).Return(mockChannel, nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitAddMember", mockGuild.ID, authUser).Return()
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"link": link,
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(mockGuild.SerializeGuild(mockChannel.ID))
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Link required", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"link": "",
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "GetGuildIdFromInvite")
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockGuildService.AssertNotCalled(t, "GetDefaultChannel")
mockSocketService.AssertNotCalled(t, "EmitAddMember")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"link": "link",
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "GetGuildIdFromInvite")
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockGuildService.AssertNotCalled(t, "GetDefaultChannel")
mockSocketService.AssertNotCalled(t, "EmitAddMember")
})
t.Run("Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
link := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUser", authUser.ID).Return(authUser, nil)
mockArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
link,
}
mockGuildService.On("GetGuildIdFromInvite", mockArgs...).Return(mockGuild.ID, nil)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockError := apperrors.NewInternal()
mockGuildService.On("UpdateGuild", mockGuild).Return(mockError)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"link": link,
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockGuildService.AssertNotCalled(t, "GetDefaultChannel")
mockSocketService.AssertNotCalled(t, "EmitAddMember")
})
t.Run("User is already in the maximum number of guilds", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
newAuthUser := fixture.GetMockUser()
for i := 0; i < model.MaximumGuilds; i++ {
mockGuild := fixture.GetMockGuild("")
newAuthUser.Guilds = append(newAuthUser.Guilds, *mockGuild)
}
mockGuildService.On("GetUser", newAuthUser.ID).Return(newAuthUser, nil)
router := getAuthenticatedTestRouter(newAuthUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"link": "link",
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.GuildLimitReached)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetUser", newAuthUser.ID)
mockGuildService.AssertNotCalled(t, "GetGuildIdFromInvite")
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockGuildService.AssertNotCalled(t, "GetDefaultChannel")
mockSocketService.AssertNotCalled(t, "EmitAddMember")
})
t.Run("User is banned from the guild", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuild.Bans = append(mockGuild.Bans, *authUser)
link := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUser", authUser.ID).Return(authUser, nil)
mockArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
link,
}
mockGuildService.On("GetGuildIdFromInvite", mockArgs...).Return(mockGuild.ID, nil)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"link": link,
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.BannedFromServer)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockGuildService.AssertNotCalled(t, "GetDefaultChannel")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockSocketService.AssertNotCalled(t, "EmitAddMember")
})
t.Run("Invalid Invite", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
link := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUser", authUser.ID).Return(authUser, nil)
mockArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
link,
}
mockError := apperrors.NewBadRequest(apperrors.InvalidInviteError)
mockGuildService.On("GetGuildIdFromInvite", mockArgs...).Return(mockGuild.ID, mockError)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"link": link,
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "GetDefaultChannel")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockSocketService.AssertNotCalled(t, "EmitAddMember")
})
t.Run("Already a member", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuild.Members = append(mockGuild.Members, *authUser)
link := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetUser", authUser.ID).Return(authUser, nil)
mockArgs := mock.Arguments{
mock.AnythingOfType("*context.emptyCtx"),
link,
}
mockGuildService.On("GetGuildIdFromInvite", mockArgs...).Return(mockGuild.ID, nil)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"link": link,
})
assert.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, "/api/guilds/join", bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.AlreadyMember)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockGuildService.AssertNotCalled(t, "GetDefaultChannel")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockSocketService.AssertNotCalled(t, "EmitAddMember")
})
}
func TestHandler_LeaveGuild(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully left the guild", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("RemoveMember", authUser.ID, mockGuild.ID).Return(nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitRemoveMember", mockGuild.ID, authUser.ID).Return()
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("AuthUser is the owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.OwnerCantLeave)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
})
t.Run("Guild not found", func(t *testing.T) {
id := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", id)
mockGuildService.On("GetGuild", id).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s", id)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", id)
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
})
t.Run("Server Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockError := apperrors.NewInternal()
mockGuildService.On("RemoveMember", authUser.ID, mockGuild.ID).Return(mockError)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
})
}
func TestHandler_DeleteGuild(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully deleted", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("DeleteGuild", mockGuild.ID).Return(nil)
mockSocketService := new(mocks.SocketService)
members := make([]string, 0)
mockSocketService.On("EmitDeleteGuild", mockGuild.ID, members).Return()
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/delete", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Not the guild owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/delete", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.DeleteGuildError)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "DeleteGuild")
mockSocketService.AssertNotCalled(t, "EmitDeleteGuild")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/delete", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "DeleteGuild")
mockSocketService.AssertNotCalled(t, "EmitDeleteGuild")
})
t.Run("Guild not found", func(t *testing.T) {
id := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", id)
mockGuildService.On("GetGuild", id).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/delete", id)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", id)
mockGuildService.AssertNotCalled(t, "DeleteGuild")
mockSocketService.AssertNotCalled(t, "EmitDeleteGuild")
})
t.Run("Server Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockError := apperrors.NewInternal()
mockGuildService.On("DeleteGuild", mockGuild.ID).Return(mockError)
mockSocketService := new(mocks.SocketService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/delete", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockSocketService.AssertNotCalled(t, "EmitDeleteGuild")
})
}
================================================
FILE: server/handler/handler.go
================================================
package handler
import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"
"log"
"net/http"
// Register swagger docs
_ "github.com/sentrionic/valkyrie/docs"
"github.com/sentrionic/valkyrie/handler/middleware"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/swaggo/files" // swagger embed files
"github.com/swaggo/gin-swagger" // gin-swagger middleware
"time"
)
// Handler struct holds required services for handler to function
type Handler struct {
userService model.UserService
friendService model.FriendService
guildService model.GuildService
channelService model.ChannelService
messageService model.MessageService
socketService model.SocketService
MaxBodyBytes int64
}
// Config will hold services that will eventually be injected into this
// handler layer on handler initialization
type Config struct {
R *gin.Engine
UserService model.UserService
FriendService model.FriendService
GuildService model.GuildService
ChannelService model.ChannelService
MessageService model.MessageService
SocketService model.SocketService
TimeoutDuration time.Duration
MaxBodyBytes int64
}
// NewHandler initializes the handler with required injected services along with http routes
// Does not return as it deals directly with a reference to the gin Engine
func NewHandler(c *Config) {
// Create a handler (which will later have injected services)
h := &Handler{
userService: c.UserService,
friendService: c.FriendService,
guildService: c.GuildService,
channelService: c.ChannelService,
messageService: c.MessageService,
socketService: c.SocketService,
MaxBodyBytes: c.MaxBodyBytes,
}
c.R.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{
"error": "No route found. Go to https://api.valkyrieapp.xyz/swagger/index.html for a list of all routes",
})
})
c.R.Use(static.Serve("/", static.LocalFile("./static", true)))
if gin.Mode() != gin.TestMode {
c.R.Use(middleware.Timeout(c.TimeoutDuration, apperrors.NewServiceUnavailable()))
}
c.R.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// Create an account group
ag := c.R.Group("api/account")
ag.POST("/register", h.Register)
ag.POST("/login", h.Login)
ag.POST("/logout", h.Logout)
ag.POST("/forgot-password", h.ForgotPassword)
ag.POST("/reset-password", h.ResetPassword)
ag.Use(middleware.AuthUser())
ag.GET("", h.GetCurrent)
ag.PUT("", h.Edit)
ag.PUT("/change-password", h.ChangePassword)
ag.GET("/me/friends", h.GetUserFriends)
ag.GET("/me/pending", h.GetUserRequests)
ag.POST("/:memberId/friend", h.SendFriendRequest)
ag.DELETE("/:memberId/friend", h.RemoveFriend)
ag.POST("/:memberId/friend/accept", h.AcceptFriendRequest)
ag.POST("/:memberId/friend/cancel", h.CancelFriendRequest)
// Create a guild group
gg := c.R.Group("api/guilds")
gg.Use(middleware.AuthUser())
gg.GET("/:guildId/members", h.GetGuildMembers)
gg.GET("/:guildId/vcmembers", h.GetVCMembers)
gg.GET("", h.GetUserGuilds)
gg.POST("/create", h.CreateGuild)
gg.GET("/:guildId/invite", h.GetInvite)
gg.DELETE("/:guildId/invite", h.DeleteGuildInvites)
gg.POST("/join", h.JoinGuild)
gg.GET("/:guildId/member", h.GetMemberSettings)
gg.PUT("/:guildId/member", h.EditMemberSettings)
gg.DELETE("/:guildId", h.LeaveGuild)
gg.PUT("/:guildId", h.EditGuild)
gg.DELETE("/:guildId/delete", h.DeleteGuild)
gg.GET("/:guildId/bans", h.GetBanList)
gg.POST("/:guildId/bans", h.BanMember)
gg.DELETE("/:guildId/bans", h.UnbanMember)
gg.POST("/:guildId/kick", h.KickMember)
// Create a channels group
cg := c.R.Group("api/channels")
cg.Use(middleware.AuthUser())
// Route parameters cause conflicts so they have to use the same parameter name
cg.GET("/:id", h.GuildChannels) // id -> guildId
cg.POST("/:id", h.CreateChannel) // id -> guildId
cg.GET("/:id/members", h.PrivateChannelMembers) // id -> channelId
cg.POST("/:id/dm", h.GetOrCreateDM) // id -> memberId
cg.GET("/me/dm", h.DirectMessages) //
cg.PUT("/:id", h.EditChannel) // id -> channelId
cg.DELETE("/:id", h.DeleteChannel) // id -> channelId
cg.DELETE("/:id/dm", h.CloseDM) // id -> channelId
// Create a messages group
mg := c.R.Group("api/messages")
mg.Use(middleware.AuthUser())
mg.GET("/:channelId", h.GetMessages)
mg.POST("/:channelId", h.CreateMessage)
mg.PUT("/:messageId", h.EditMessage)
mg.DELETE("/:messageId", h.DeleteMessage)
}
// setUserSession saves the users ID in the session
func setUserSession(c *gin.Context, id string) {
session := sessions.Default(c)
session.Set("userId", id)
if err := session.Save(); err != nil {
log.Printf("error setting the session: %v\n", err.Error())
}
}
func toFieldErrorResponse(c *gin.Context, field, message string) {
c.JSON(http.StatusBadRequest, gin.H{
"errors": []model.FieldError{
{
Field: field,
Message: message,
},
},
})
}
================================================
FILE: server/handler/member_handler.go
================================================
package handler
import (
"github.com/gin-gonic/gin"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"log"
"net/http"
"strings"
)
/*
* MemberHandler contains all routes related to mod actions (/api/guilds)
*/
// memberReq contains the MemberId of the user
// that needs to be moderated
type memberReq struct {
MemberId string `json:"memberId"`
} //@name MemberRequest
func (r memberReq) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.MemberId, validation.Required, is.UTFDigit),
)
}
// GetMemberSettings gets the current user's role color and nickname
// for the given guild
// GetMemberSettings godoc
// @Tags Members
// @Summary Get Member Settings
// @Produce json
// @Param guildId path string true "Guild ID"
// @Success 200 {object} model.MemberSettings
// @Failure 404 {object} model.ErrorResponse
// @Router /guilds/{guildId}/member [get]
func (h *Handler) GetMemberSettings(c *gin.Context) {
guildId := c.Param("guildId")
userId := c.MustGet("userId").(string)
_, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
settings, err := h.guildService.GetMemberSettings(userId, guildId)
if err != nil {
log.Printf("Unable to find settings: %v\n%v", userId, err)
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, settings)
}
type memberSettingsReq struct {
Nickname *string `json:"nickname"`
Color *string `json:"color"`
} //@name MemberSettingsRequest
func (r memberSettingsReq) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Nickname, validation.NilOrNotEmpty, validation.Length(3, 30)),
validation.Field(&r.Color, validation.NilOrNotEmpty, is.HexColor),
)
}
func (r *memberSettingsReq) sanitize() {
if r.Nickname != nil {
nickname := strings.TrimSpace(*r.Nickname)
r.Nickname = &nickname
}
}
// EditMemberSettings changes the current user's role color and nickname
// for the given guild
// EditMemberSettings godoc
// @Tags Members
// @Summary Edit Member Settings
// @Accepts json
// @Produce json
// @Param request body memberSettingsReq true "Edit Member"
// @Param guildId path string true "Guild ID"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorsResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/{guildId}/member [put]
func (h *Handler) EditMemberSettings(c *gin.Context) {
var req memberSettingsReq
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
userId := c.MustGet("userId").(string)
// Check if the user is a member of the guild
if !isMember(guild, userId) {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
settings := &model.MemberSettings{
Nickname: req.Nickname,
Color: req.Color,
}
err = h.guildService.UpdateMemberSettings(settings, userId, guildId)
if err != nil {
log.Printf("Unable to update settings for user: %v\n%v", userId, err)
e := apperrors.NewInternal()
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
c.JSON(http.StatusOK, true)
}
// GetBanList returns a list of all banned users for the given guild
// GetBanList godoc
// @Tags Members
// @Summary Get Guild Ban list
// @Produce json
// @Param guildId path string true "Guild ID"
// @Success 200 {array} model.BanResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/{guildId}/bans [get]
func (h *Handler) GetBanList(c *gin.Context) {
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
userId := c.MustGet("userId").(string)
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
bans, err := h.guildService.GetBanList(guildId)
if err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// If the guild does not have any bans, return an empty array
if len(*bans) == 0 {
empty := make([]model.BanResponse, 0)
c.JSON(http.StatusOK, empty)
return
}
c.JSON(http.StatusOK, bans)
}
// BanMember bans the provided member from the given guild
// BanMember godoc
// @Tags Members
// @Summary Ban Member
// @Produce json
// @Param guildId path string true "Guild ID"
// @Param request body memberReq true "Member ID"
// @Success 200 {array} model.Success
// @Failure 400 {object} model.ErrorResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/{guildId}/bans [post]
func (h *Handler) BanMember(c *gin.Context) {
var req memberReq
if ok := bindData(c, &req); !ok {
return
}
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
userId := c.MustGet("userId").(string)
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
member, err := h.guildService.GetUser(req.MemberId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", req.MemberId, err)
e := apperrors.NewNotFound("user", req.MemberId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if member.ID == userId {
e := apperrors.NewBadRequest(apperrors.BanYourselfError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
guild.Bans = append(guild.Bans, *member)
if err = h.guildService.UpdateGuild(guild); err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
err = h.guildService.RemoveMember(req.MemberId, guildId)
if err != nil {
log.Printf("Failed to ban member: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Emit signals to remove the member from the guild
h.socketService.EmitRemoveMember(guild.ID, member.ID)
h.socketService.EmitRemoveFromGuild(member.ID, guildId)
c.JSON(http.StatusOK, true)
}
// UnbanMember unbans the specified user from the given guild
// BanMember godoc
// @Tags Members
// @Summary Unban Member
// @Produce json
// @Param guildId path string true "Guild ID"
// @Param request body memberReq true "Member ID"
// @Success 200 {array} model.Success
// @Failure 400 {object} model.ErrorResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/{guildId}/bans [delete]
func (h *Handler) UnbanMember(c *gin.Context) {
var req memberReq
if ok := bindData(c, &req); !ok {
return
}
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
userId := c.MustGet("userId").(string)
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if req.MemberId == userId {
e := apperrors.NewBadRequest(apperrors.UnbanYourselfError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if err := h.guildService.UnbanMember(req.MemberId, guildId); err != nil {
log.Printf("Failed to unban member: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
c.JSON(http.StatusOK, true)
}
// KickMember kicks the provided member from the given guild
// KickMember godoc
// @Tags Members
// @Summary Kick Member
// @Produce json
// @Param guildId path string true "Guild ID"
// @Param request body memberReq true "Member ID"
// @Success 200 {array} model.Success
// @Failure 400 {object} model.ErrorResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /guilds/{guildId}/kick [post]
func (h *Handler) KickMember(c *gin.Context) {
var req memberReq
if ok := bindData(c, &req); !ok {
return
}
guildId := c.Param("guildId")
guild, err := h.guildService.GetGuild(guildId)
if err != nil {
e := apperrors.NewNotFound("guild", guildId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
userId := c.MustGet("userId").(string)
if guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.MustBeOwner)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
member, err := h.guildService.GetUser(req.MemberId)
if err != nil {
log.Printf("Unable to find user: %v\n%v", req.MemberId, err)
e := apperrors.NewNotFound("user", req.MemberId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if member.ID == userId {
e := apperrors.NewBadRequest(apperrors.KickYourselfError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
err = h.guildService.RemoveMember(req.MemberId, guildId)
if err != nil {
log.Printf("Failed to kick member: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Emit signals to remove the member from the guild
h.socketService.EmitRemoveMember(guild.ID, member.ID)
h.socketService.EmitRemoveFromGuild(member.ID, guildId)
c.JSON(http.StatusOK, true)
}
================================================
FILE: server/handler/member_handler_test.go
================================================
package handler
import (
"bytes"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandler_GetMemberSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
mockGuild := fixture.GetMockGuild("")
settings := &model.MemberSettings{
Nickname: nil,
Color: nil,
}
t.Run("Successfully fetched settings", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockArgs := mock.Arguments{
authUser.ID,
mockGuild.ID,
}
mockGuildService.On("GetMemberSettings", mockArgs...).Return(settings, nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/member", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(settings)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/member", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "GetMemberSettings")
})
t.Run("Guild not found", func(t *testing.T) {
id := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", id)
mockGuildService.On("GetGuild", id).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/member", id)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", id)
mockGuildService.AssertNotCalled(t, "GetMemberSettings")
})
t.Run("Server Error", func(t *testing.T) {
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockError := apperrors.NewNotFound("user", authUser.ID)
mockArgs := mock.Arguments{
authUser.ID,
mockGuild.ID,
}
mockGuildService.On("GetMemberSettings", mockArgs...).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/member", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
}
func TestHandler_EditMemberSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
nickname := fixture.Username()
color := "#fff"
settings := &model.MemberSettings{
Nickname: &nickname,
Color: &color,
}
t.Run("Successfully edited settings", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuild.Members = append(mockGuild.Members, *authUser)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockArgs := mock.Arguments{
settings,
authUser.ID,
mockGuild.ID,
}
mockGuildService.On("UpdateMemberSettings", mockArgs...).Return(nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{
"nickname": nickname,
"color": color,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/member", mockGuild.ID)
request, err := http.NewRequest(http.MethodPut, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Successfully reset member settings", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuild.Members = append(mockGuild.Members, *authUser)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockArgs := mock.Arguments{
&model.MemberSettings{},
authUser.ID,
mockGuild.ID,
}
mockGuildService.On("UpdateMemberSettings", mockArgs...).Return(nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{
"nickname": nil,
"color": nil,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/member", mockGuild.ID)
request, err := http.NewRequest(http.MethodPut, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Not a member of the server", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{
"nickname": nickname,
"color": color,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/member", mockGuild.ID)
request, err := http.NewRequest(http.MethodPut, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewNotFound("guild", mockGuild.ID)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockGuildService.AssertNotCalled(t, "UpdateMemberSettings")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/member", mockGuild.ID)
request, err := http.NewRequest(http.MethodPut, reqUrl, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "UpdateMemberSettings")
})
t.Run("Guild not found", func(t *testing.T) {
id := fixture.RandID()
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", id)
mockGuildService.On("GetGuild", id).Return(nil, mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{
"nickname": nickname,
"color": color,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/member", id)
request, err := http.NewRequest(http.MethodPut, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", id)
mockGuildService.AssertNotCalled(t, "UpdateMemberSettings")
})
t.Run("Server Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuild.Members = append(mockGuild.Members, *authUser)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockError := apperrors.NewInternal()
mockArgs := mock.Arguments{
settings,
authUser.ID,
mockGuild.ID,
}
mockGuildService.On("UpdateMemberSettings", mockArgs...).Return(mockError)
// a response recorder for getting written http response
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{
"nickname": nickname,
"color": color,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/member", mockGuild.ID)
request, err := http.NewRequest(http.MethodPut, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
}
func TestHandler_EditMemberSettings_BadRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockUser := fixture.GetMockUser()
router := getAuthenticatedTestRouter(mockUser.ID)
mockGuildService := new(mocks.GuildService)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
testCases := []struct {
name string
body gin.H
}{
{
name: "Nickname too short",
body: gin.H{
"nickname": fixture.RandStringRunes(2),
},
},
{
name: "Nickname too long",
body: gin.H{
"nickname": fixture.RandStringRunes(32),
},
},
{
name: "Color not a hex color",
body: gin.H{
"color": fixture.RandStringRunes(6),
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
reqBody, err := json.Marshal(tc.body)
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/member", fixture.RandID())
request, err := http.NewRequest(http.MethodPut, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockGuildService.AssertNotCalled(t, "UpdateMemberSettings")
})
}
}
func TestHandler_GetBanList(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successful Fetch", func(t *testing.T) {
response := make([]model.BanResponse, 0)
mockGuild := fixture.GetMockGuild(authUser.ID)
for i := 0; i < 5; i++ {
mockUser := fixture.GetMockUser()
response = append(response, model.BanResponse{
Id: mockUser.ID,
Username: mockUser.Username,
Image: mockUser.Image,
})
}
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("GetBanList", mockGuild.ID).Return(&response, nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(response)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Not the owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.MustBeOwner)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockGuildService.AssertNotCalled(t, "GetBanList")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuildService := new(mocks.GuildService)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "GetBanList")
})
t.Run("Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockError := apperrors.NewInternal()
mockGuildService.On("GetBanList", mockGuild.ID).Return(nil, mockError)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodGet, reqUrl, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
}
func TestHandler_BanMember(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successful Ban", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("GetUser", mockMember.ID).Return(mockMember, nil)
mockGuildService.On("UpdateGuild", mockGuild).Return(nil)
args := mock.Arguments{
mockMember.ID,
mockGuild.ID,
}
mockGuildService.On("RemoveMember", args...).Return(nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitRemoveMember", mockGuild.ID, mockMember.ID)
mockSocketService.On("EmitRemoveFromGuild", mockMember.ID, mockGuild.ID)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Not the owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.MustBeOwner)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("GetUser", mockMember.ID).Return(mockMember, nil)
mockGuildService.On("UpdateGuild", mockGuild).Return(nil)
mockError := apperrors.NewInternal()
args := mock.Arguments{
mockMember.ID,
mockGuild.ID,
}
mockGuildService.On("RemoveMember", args...).Return(mockError)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("Guild not found", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", mockGuild.ID)
mockGuildService.On("GetGuild", mockGuild.ID).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("Member not found", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockError := apperrors.NewNotFound("user", mockMember.ID)
mockGuildService.On("GetUser", mockMember.ID).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertCalled(t, "GetUser", mockMember.ID)
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("MemberId required", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "UpdateGuild")
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("MemberId and AuthUserId are equal", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("GetUser", authUser.ID).Return(authUser, nil)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": authUser.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.BanYourselfError)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertCalled(t, "GetUser", authUser.ID)
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
}
func TestHandler_KickMember(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successful Kick", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("GetUser", mockMember.ID).Return(mockMember, nil)
args := mock.Arguments{
mockMember.ID,
mockGuild.ID,
}
mockGuildService.On("RemoveMember", args...).Return(nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitRemoveMember", mockGuild.ID, mockMember.ID)
mockSocketService.On("EmitRemoveFromGuild", mockMember.ID, mockGuild.ID)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/kick", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Not the owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/kick", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.MustBeOwner)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/kick", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("GetUser", mockMember.ID).Return(mockMember, nil)
mockError := apperrors.NewInternal()
args := mock.Arguments{
mockMember.ID,
mockGuild.ID,
}
mockGuildService.On("RemoveMember", args...).Return(mockError)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/kick", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("Guild not found", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", mockGuild.ID)
mockGuildService.On("GetGuild", mockGuild.ID).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/kick", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("Member not found", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockError := apperrors.NewNotFound("user", mockMember.ID)
mockGuildService.On("GetUser", mockMember.ID).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/kick", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertCalled(t, "GetUser", mockMember.ID)
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("MemberId required", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/kick", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "GetUser")
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
t.Run("MemberId and AuthUserId are equal", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockGuildService.On("GetUser", authUser.ID).Return(authUser, nil)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": authUser.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/kick", mockGuild.ID)
request, err := http.NewRequest(http.MethodPost, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.KickYourselfError)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertCalled(t, "GetUser", authUser.ID)
mockGuildService.AssertNotCalled(t, "RemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveMember")
mockSocketService.AssertNotCalled(t, "EmitRemoveFromGuild")
})
}
func TestHandler_UnbanMember(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successful Unban", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
args := mock.Arguments{
mockMember.ID,
mockGuild.ID,
}
mockGuildService.On("UnbanMember", args...).Return(nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Not the owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.MustBeOwner)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "UnbanMember")
})
t.Run("Unauthorized", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "UnbanMember")
})
t.Run("Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockError := apperrors.NewInternal()
args := mock.Arguments{
mockMember.ID,
mockGuild.ID,
}
mockGuildService.On("UnbanMember", args...).Return(mockError)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
SocketService: mockSocketService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertExpectations(t)
})
t.Run("Guild not found", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockMember := fixture.GetMockUser()
mockGuildService := new(mocks.GuildService)
mockError := apperrors.NewNotFound("guild", mockGuild.ID)
mockGuildService.On("GetGuild", mockGuild.ID).Return(nil, mockError)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": mockMember.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "UnbanMember")
})
t.Run("MemberId required", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockGuildService.AssertNotCalled(t, "GetGuild")
mockGuildService.AssertNotCalled(t, "UnbanMember")
})
t.Run("MemberId and AuthUserId are equal", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
GuildService: mockGuildService,
})
reqBody, err := json.Marshal(gin.H{
"memberId": authUser.ID,
})
assert.NoError(t, err)
reqUrl := fmt.Sprintf("/api/guilds/%s/bans", mockGuild.ID)
request, err := http.NewRequest(http.MethodDelete, reqUrl, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rr, request)
mockError := apperrors.NewBadRequest(apperrors.UnbanYourselfError)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockGuildService.AssertNotCalled(t, "UnbanMember")
})
}
================================================
FILE: server/handler/message_handler.go
================================================
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
validation "github.com/go-ozzo/ozzo-validation/v4"
gonanoid "github.com/matoous/go-nanoid"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"log"
"mime/multipart"
"net/http"
"strings"
"time"
)
/*
* MessageHandler contains all routes related to message actions (/api/messages)
*/
// GetMessages returns messages for the given channel
// It returns the most recent 35 or the ones after the given cursor
// GetMessages godoc
// @Tags Messages
// @Summary Get Channel Messages
// @Produce json
// @Param channelId path string true "Channel ID"
// @Param cursor query string false "Cursor Pagination using the createdAt field"
// @Success 200 {array} model.MessageResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Router /messages/{channelId} [get]
func (h *Handler) GetMessages(c *gin.Context) {
channelId := c.Param("channelId")
userId := c.MustGet("userId").(string)
channel, err := h.channelService.Get(channelId)
if err != nil {
e := apperrors.NewNotFound("channel", channelId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if the user has access to said channel
err = h.channelService.IsChannelMember(channel, userId)
if err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Cursor is based on the created_at field of the message
cursor := c.Query("cursor")
messages, err := h.messageService.GetMessages(userId, channel, cursor)
if err != nil {
e := apperrors.NewNotFound("messages", channelId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// If the channel does not have any messages, return an empty array
if len(*messages) == 0 {
var empty = make([]model.MessageResponse, 0)
c.JSON(http.StatusOK, empty)
return
}
c.JSON(http.StatusOK, messages)
}
// messageRequest contains all field required to create a message.
// Either text or file must be provided
type messageRequest struct {
// Maximum 2000 characters
Text *string `form:"text"`
// image/* or audio/*
File *multipart.FileHeader `form:"file" swaggertype:"string" format:"binary"`
} //@name MessageRequest
func (r messageRequest) validate() error {
return validation.ValidateStruct(&r,
validation.Field(&r.Text,
validation.NilOrNotEmpty,
validation.Required.When(r.File == nil).
Error(apperrors.MessageOrFileRequired),
validation.Length(1, 2000),
),
)
}
func (r *messageRequest) sanitize() {
if r.Text != nil {
text := strings.TrimSpace(*r.Text)
r.Text = &text
}
}
// CreateMessage creates a message in the given channel
// CreateMessage godoc
// @Tags Messages
// @Summary Create Messages
// @Accepts mpfd
// @Produce json
// @Param channelId path string true "Channel ID"
// @Param request body messageRequest true "Create Message"
// @Success 201 {object} model.Success
// @Failure 400 {object} model.ErrorsResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /messages/{channelId} [post]
func (h *Handler) CreateMessage(c *gin.Context) {
channelId := c.Param("channelId")
userId := c.MustGet("userId").(string)
var req messageRequest
// Bind incoming json to struct and check for validation errors
if ok := bindData(c, &req); !ok {
return
}
req.sanitize()
channel, err := h.channelService.Get(channelId)
if err != nil {
e := apperrors.NewNotFound("channel", channelId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if the user has access to said channel
err = h.channelService.IsChannelMember(channel, userId)
if err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
author, err := h.userService.Get(userId)
if err != nil {
e := apperrors.NewNotFound("user", userId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
params := model.Message{
UserId: userId,
ChannelId: channel.ID,
}
params.Text = req.Text
if req.File != nil {
mimeType := req.File.Header.Get("Content-Type")
if valid := isAllowedFileType(mimeType); !valid {
toFieldErrorResponse(c, "File", apperrors.InvalidImageType)
return
}
// Prevent file upload on the live server.
// Remove the if part if you do want upload
var attachment *model.Attachment
if gin.Mode() == gin.ReleaseMode {
id, _ := gonanoid.Nanoid(20)
// Random image to test files in the app
attachment = &model.Attachment{
ID: id,
Url: fmt.Sprintf("https://picsum.photos/seed/%s/600", id),
FileType: "image/jpeg",
Filename: id,
}
} else {
attachment, err = h.messageService.UploadFile(req.File, channel.ID)
if err != nil {
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
}
params.Attachment = attachment
}
message, err := h.messageService.CreateMessage(¶ms)
if err != nil {
log.Printf("Failed to create message: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
response := model.MessageResponse{
Id: message.ID,
Text: message.Text,
CreatedAt: message.CreatedAt,
UpdatedAt: message.UpdatedAt,
Attachment: message.Attachment,
User: model.MemberResponse{
Id: author.ID,
Username: author.Username,
Image: author.Image,
IsOnline: author.IsOnline,
CreatedAt: author.CreatedAt,
UpdatedAt: author.UpdatedAt,
IsFriend: false,
},
}
// Get member settings if it is not a DM
if !channel.IsDM {
settings, _ := h.guildService.GetMemberSettings(userId, *channel.GuildID)
response.User.Nickname = settings.Nickname
response.User.Color = settings.Color
}
// Emit new message to the channel
h.socketService.EmitNewMessage(channelId, &response)
if channel.IsDM {
// Open the DM and push it to the top
_ = h.channelService.OpenDMForAll(channelId)
// Post a notification
h.socketService.EmitNewDMNotification(channelId, author)
} else {
// Update last activity in channel
channel.LastActivity = time.Now()
_ = h.channelService.UpdateChannel(channel)
// Post a notification
h.socketService.EmitNewNotification(*channel.GuildID, channelId)
}
c.JSON(http.StatusCreated, true)
}
// EditMessage edits the given message with the given text
// EditMessage godoc
// @Tags Messages
// @Summary Edit Messages
// @Accepts json
// @Produce json
// @Param messageId path string true "Message ID"
// @Param request body messageRequest true "Edit Message"
// @Success 200 {object} model.Success
// @Failure 400 {object} model.ErrorsResponse
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /messages/{messageId} [put]
func (h *Handler) EditMessage(c *gin.Context) {
messageId := c.Param("messageId")
userId := c.MustGet("userId").(string)
var req messageRequest
// Bind incoming json to struct and check for validation errors
if ok := bindData(c, &req); !ok {
return
}
message, err := h.messageService.Get(messageId)
if err != nil {
e := apperrors.NewNotFound("message", messageId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if message.UserId != userId {
e := apperrors.NewAuthorization(apperrors.EditMessageError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
message.Text = req.Text
if err = h.messageService.UpdateMessage(message); err != nil {
log.Printf("Failed to edit message: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
response := model.MessageResponse{
Id: message.ID,
Text: message.Text,
CreatedAt: message.CreatedAt,
UpdatedAt: message.UpdatedAt,
Attachment: message.Attachment,
User: model.MemberResponse{
Id: userId,
},
}
// Emit edited message to the channel
h.socketService.EmitEditMessage(message.ChannelId, &response)
c.JSON(http.StatusOK, true)
}
// DeleteMessage deletes the given message
// DeleteMessage godoc
// @Tags Messages
// @Summary Delete Messages
// @Produce json
// @Param messageId path string true "Message ID"
// @Success 200 {object} model.Success
// @Failure 401 {object} model.ErrorResponse
// @Failure 404 {object} model.ErrorResponse
// @Failure 500 {object} model.ErrorResponse
// @Router /messages/{messageId} [delete]
func (h *Handler) DeleteMessage(c *gin.Context) {
messageId := c.Param("messageId")
userId := c.MustGet("userId").(string)
message, err := h.messageService.Get(messageId)
if err != nil {
e := apperrors.NewNotFound("message", messageId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
channel, err := h.channelService.Get(message.ChannelId)
if err != nil {
e := apperrors.NewNotFound("message", messageId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Check if message author or guild owner
if !channel.IsDM {
guild, err := h.guildService.GetGuild(*channel.GuildID)
if err != nil {
e := apperrors.NewNotFound("message", messageId)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
if message.UserId != userId && guild.OwnerId != userId {
e := apperrors.NewAuthorization(apperrors.DeleteMessageError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
// Only message author check required
} else {
if message.UserId != userId {
e := apperrors.NewAuthorization(apperrors.DeleteDMMessageError)
c.JSON(e.Status(), gin.H{
"error": e,
})
return
}
}
if err = h.messageService.DeleteMessage(message); err != nil {
log.Printf("Failed to delete message: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
// Emit delete message to the channel
h.socketService.EmitDeleteMessage(message.ChannelId, message.ID)
c.JSON(http.StatusOK, true)
}
================================================
FILE: server/handler/message_handler_test.go
================================================
package handler
import (
"bytes"
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestHandler_GetMessages(t *testing.T) {
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successful fetch", func(t *testing.T) {
mockChannel := fixture.GetMockChannel(fixture.RandID())
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("IsChannelMember", mockChannel, authUser.ID).Return(nil)
mockMessageService := new(mocks.MessageService)
args := mock.Arguments{
authUser.ID,
mockChannel,
"",
}
response := make([]model.MessageResponse, 0)
for i := 0; i < 25; i++ {
message := fixture.GetMockMessageResponse("", mockChannel.ID)
response = append(response, *message)
}
mockMessageService.On("GetMessages", args...).Return(&response, nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
})
request, err := http.NewRequest(http.MethodGet, "/api/messages/"+mockChannel.ID, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(response)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertExpectations(t)
mockMessageService.AssertExpectations(t)
})
t.Run("No channel found", func(t *testing.T) {
id := fixture.RandID()
mockError := apperrors.NewNotFound("channel", id)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", id).Return(nil, mockError)
mockMessageService := new(mocks.MessageService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
})
request, err := http.NewRequest(http.MethodGet, "/api/messages/"+id, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", id)
mockChannelService.AssertNotCalled(t, "IsChannelMember")
mockMessageService.AssertNotCalled(t, "GetMessages")
})
t.Run("Not a member of the channel", func(t *testing.T) {
mockChannel := fixture.GetMockChannel(fixture.RandID())
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockError := apperrors.NewAuthorization(apperrors.Unauthorized)
mockChannelService.On("IsChannelMember", mockChannel, authUser.ID).Return(mockError)
mockMessageService := new(mocks.MessageService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
})
request, err := http.NewRequest(http.MethodGet, "/api/messages/"+mockChannel.ID, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertExpectations(t)
mockMessageService.AssertNotCalled(t, "GetMessages")
})
t.Run("Unauthorized", func(t *testing.T) {
id := fixture.RandID()
mockChannelService := new(mocks.ChannelService)
mockMessageService := new(mocks.MessageService)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
})
request, err := http.NewRequest(http.MethodGet, "/api/messages/"+id, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertNotCalled(t, "Get")
mockChannelService.AssertNotCalled(t, "IsChannelMember")
mockMessageService.AssertNotCalled(t, "GetMessages")
})
t.Run("Server Error", func(t *testing.T) {
mockChannel := fixture.GetMockChannel(fixture.RandID())
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("IsChannelMember", mockChannel, authUser.ID).Return(nil)
mockMessageService := new(mocks.MessageService)
args := mock.Arguments{
authUser.ID,
mockChannel,
"",
}
mockError := apperrors.NewNotFound("messages", mockChannel.ID)
mockMessageService.On("GetMessages", args...).Return(nil, mockError)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
})
request, err := http.NewRequest(http.MethodGet, "/api/messages/"+mockChannel.ID, nil)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertExpectations(t)
mockMessageService.AssertExpectations(t)
})
}
func TestHandler_CreateMessage(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully created text message", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockMessage := fixture.GetMockMessage(authUser.ID, mockChannel.ID)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("IsChannelMember", mockChannel, authUser.ID).Return(nil)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", authUser.ID).Return(authUser, nil)
params := model.Message{
UserId: mockMessage.UserId,
ChannelId: mockMessage.ChannelId,
Text: mockMessage.Text,
}
mockMessageService := new(mocks.MessageService)
mockMessageService.On("CreateMessage", ¶ms).Return(mockMessage, nil)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetMemberSettings", authUser.ID, mockGuild.ID).Return(&model.MemberSettings{}, nil)
mockSocketService := new(mocks.SocketService)
response := model.MessageResponse{
Id: mockMessage.ID,
Text: mockMessage.Text,
CreatedAt: mockMessage.CreatedAt,
UpdatedAt: mockMessage.UpdatedAt,
Attachment: mockMessage.Attachment,
User: model.MemberResponse{
Id: authUser.ID,
Username: authUser.Username,
Image: authUser.Image,
IsOnline: authUser.IsOnline,
CreatedAt: authUser.CreatedAt,
UpdatedAt: authUser.UpdatedAt,
IsFriend: false,
},
}
mockSocketService.On("EmitNewMessage", mockChannel.ID, &response).Return()
mockChannelService.On("UpdateChannel", mockChannel).Return(nil)
mockSocketService.On("EmitNewNotification", mockGuild.ID, mockChannel.ID)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
GuildService: mockGuildService,
SocketService: mockSocketService,
UserService: mockUserService,
})
form := url.Values{}
form.Add("text", *mockMessage.Text)
request, err := http.NewRequest(http.MethodPost, "/api/messages/"+mockChannel.ID, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertExpectations(t)
mockMessageService.AssertExpectations(t)
mockGuildService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
mockUserService.AssertExpectations(t)
})
t.Run("Channel not found", func(t *testing.T) {
id := fixture.RandID()
mockError := apperrors.NewNotFound("channel", id)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", id).Return(nil, mockError)
mockUserService := new(mocks.UserService)
mockMessageService := new(mocks.MessageService)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
GuildService: mockGuildService,
SocketService: mockSocketService,
UserService: mockUserService,
})
form := url.Values{}
form.Add("text", fixture.RandStringRunes(8))
request, err := http.NewRequest(http.MethodPost, "/api/messages/"+id, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", id)
mockChannelService.AssertNotCalled(t, "IsChannelMember")
mockUserService.AssertNotCalled(t, "Get")
mockMessageService.AssertNotCalled(t, "CreateMessage")
mockSocketService.AssertNotCalled(t, "EmitNewMessage")
})
t.Run("Not a member of the channel", func(t *testing.T) {
mockChannel := fixture.GetMockChannel("")
mockError := apperrors.NewAuthorization(apperrors.Unauthorized)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("IsChannelMember", mockChannel, authUser.ID).Return(mockError)
mockUserService := new(mocks.UserService)
mockMessageService := new(mocks.MessageService)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
GuildService: mockGuildService,
SocketService: mockSocketService,
UserService: mockUserService,
})
form := url.Values{}
form.Add("text", fixture.RandStringRunes(8))
request, err := http.NewRequest(http.MethodPost, "/api/messages/"+mockChannel.ID, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockChannelService.AssertCalled(t, "IsChannelMember", mockChannel, authUser.ID)
mockUserService.AssertNotCalled(t, "Get")
mockMessageService.AssertNotCalled(t, "CreateMessage")
mockSocketService.AssertNotCalled(t, "EmitNewMessage")
})
t.Run("Unauthorized", func(t *testing.T) {
id := fixture.RandID()
mockChannelService := new(mocks.ChannelService)
mockUserService := new(mocks.UserService)
mockMessageService := new(mocks.MessageService)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getTestRouter()
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
GuildService: mockGuildService,
SocketService: mockSocketService,
UserService: mockUserService,
})
form := url.Values{}
form.Add("text", fixture.RandStringRunes(8))
request, err := http.NewRequest(http.MethodPost, "/api/messages/"+id, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
router.ServeHTTP(rr, request)
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertNotCalled(t, "Get")
mockChannelService.AssertNotCalled(t, "IsChannelMember")
mockUserService.AssertNotCalled(t, "Get")
mockMessageService.AssertNotCalled(t, "CreateMessage")
mockSocketService.AssertNotCalled(t, "EmitNewMessage")
})
t.Run("Text Message Creation failure", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockMessage := fixture.GetMockMessage(authUser.ID, mockChannel.ID)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("IsChannelMember", mockChannel, authUser.ID).Return(nil)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", authUser.ID).Return(authUser, nil)
params := model.Message{
UserId: mockMessage.UserId,
ChannelId: mockMessage.ChannelId,
Text: mockMessage.Text,
}
mockError := apperrors.NewInternal()
mockMessageService := new(mocks.MessageService)
mockMessageService.On("CreateMessage", ¶ms).Return(nil, mockError)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
GuildService: mockGuildService,
SocketService: mockSocketService,
UserService: mockUserService,
})
form := url.Values{}
form.Add("text", *mockMessage.Text)
request, err := http.NewRequest(http.MethodPost, "/api/messages/"+mockChannel.ID, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockChannelService.AssertCalled(t, "IsChannelMember", mockChannel, authUser.ID)
mockMessageService.AssertCalled(t, "CreateMessage", ¶ms)
mockUserService.AssertCalled(t, "Get", authUser.ID)
mockChannelService.AssertNotCalled(t, "UpdateChannel")
mockSocketService.AssertNotCalled(t, "EmitNewMessage")
})
t.Run("Disallowed mimetype", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("IsChannelMember", mockChannel, authUser.ID).Return(nil)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", authUser.ID).Return(authUser, nil)
mockMessageService := new(mocks.MessageService)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
GuildService: mockGuildService,
SocketService: mockSocketService,
UserService: mockUserService,
})
multipartImageFixture := fixture.NewMultipartImage("image.txt", "image/txt")
defer multipartImageFixture.Close()
request, err := http.NewRequest(http.MethodPost, "/api/messages/"+mockChannel.ID, multipartImageFixture.MultipartBody)
assert.NoError(t, err)
request.Header.Set("Content-Type", multipartImageFixture.ContentType)
router.ServeHTTP(rr, request)
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockChannelService.AssertCalled(t, "IsChannelMember", mockChannel, authUser.ID)
mockUserService.AssertCalled(t, "Get", authUser.ID)
mockMessageService.AssertNotCalled(t, "UploadFile")
mockMessageService.AssertNotCalled(t, "CreateMessage")
mockChannelService.AssertNotCalled(t, "UpdateChannel")
mockSocketService.AssertNotCalled(t, "EmitNewMessage")
})
t.Run("Image Message Creation Success", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockMessage := fixture.GetMockMessage(authUser.ID, mockChannel.ID)
mockMessage.Text = nil
uploadImageFixture := fixture.NewMultipartImage("image.png", "image/png")
defer uploadImageFixture.Close()
formFile := uploadImageFixture.GetFormFile()
attachment := &model.Attachment{
ID: fixture.RandID(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Url: fixture.RandStringRunes(8),
FileType: "image/png",
Filename: fixture.RandStringRunes(8),
MessageId: mockMessage.ID,
}
mockMessage.Attachment = attachment
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("IsChannelMember", mockChannel, authUser.ID).Return(nil)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", authUser.ID).Return(authUser, nil)
params := model.Message{
UserId: mockMessage.UserId,
ChannelId: mockMessage.ChannelId,
Attachment: attachment,
}
mockMessageService := new(mocks.MessageService)
mockMessageService.On("UploadFile", formFile, mockChannel.ID).Return(attachment, nil)
mockMessageService.On("CreateMessage", ¶ms).Return(mockMessage, nil)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetMemberSettings", authUser.ID, mockGuild.ID).Return(&model.MemberSettings{}, nil)
mockSocketService := new(mocks.SocketService)
response := model.MessageResponse{
Id: mockMessage.ID,
Text: nil,
CreatedAt: mockMessage.CreatedAt,
UpdatedAt: mockMessage.UpdatedAt,
Attachment: attachment,
User: model.MemberResponse{
Id: authUser.ID,
Username: authUser.Username,
Image: authUser.Image,
IsOnline: authUser.IsOnline,
CreatedAt: authUser.CreatedAt,
UpdatedAt: authUser.UpdatedAt,
IsFriend: false,
},
}
mockSocketService.On("EmitNewMessage", mockChannel.ID, &response).Return()
mockChannelService.On("UpdateChannel", mockChannel).Return(nil)
mockSocketService.On("EmitNewNotification", mockGuild.ID, mockChannel.ID)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
GuildService: mockGuildService,
SocketService: mockSocketService,
UserService: mockUserService,
})
multipartImageFixture := fixture.NewMultipartImage("image.png", "image/png")
defer multipartImageFixture.Close()
request, err := http.NewRequest(http.MethodPost, "/api/messages/"+mockChannel.ID, multipartImageFixture.MultipartBody)
assert.NoError(t, err)
request.Header.Set("Content-Type", multipartImageFixture.ContentType)
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertExpectations(t)
mockMessageService.AssertExpectations(t)
mockGuildService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
mockUserService.AssertExpectations(t)
})
t.Run("DM channel message success", func(t *testing.T) {
mockChannel := fixture.GetMockChannel("")
mockChannel.IsDM = true
mockMessage := fixture.GetMockMessage(authUser.ID, mockChannel.ID)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockChannelService.On("IsChannelMember", mockChannel, authUser.ID).Return(nil)
mockUserService := new(mocks.UserService)
mockUserService.On("Get", authUser.ID).Return(authUser, nil)
params := model.Message{
UserId: mockMessage.UserId,
ChannelId: mockMessage.ChannelId,
Text: mockMessage.Text,
}
mockMessageService := new(mocks.MessageService)
mockMessageService.On("CreateMessage", ¶ms).Return(mockMessage, nil)
mockSocketService := new(mocks.SocketService)
response := model.MessageResponse{
Id: mockMessage.ID,
Text: mockMessage.Text,
CreatedAt: mockMessage.CreatedAt,
UpdatedAt: mockMessage.UpdatedAt,
Attachment: mockMessage.Attachment,
User: model.MemberResponse{
Id: authUser.ID,
Username: authUser.Username,
Image: authUser.Image,
IsOnline: authUser.IsOnline,
CreatedAt: authUser.CreatedAt,
UpdatedAt: authUser.UpdatedAt,
IsFriend: false,
},
}
mockSocketService.On("EmitNewMessage", mockChannel.ID, &response).Return()
mockSocketService.On("EmitNewDMNotification", mockChannel.ID, authUser).Return()
mockChannelService.On("OpenDMForAll", mockChannel.ID).Return(nil)
rr := httptest.NewRecorder()
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
ChannelService: mockChannelService,
MessageService: mockMessageService,
SocketService: mockSocketService,
UserService: mockUserService,
})
form := url.Values{}
form.Add("text", *mockMessage.Text)
request, err := http.NewRequest(http.MethodPost, "/api/messages/"+mockChannel.ID, strings.NewReader(form.Encode()))
assert.NoError(t, err)
request.Form = form
router.ServeHTTP(rr, request)
respBody, err := json.Marshal(true)
assert.NoError(t, err)
assert.Equal(t, http.StatusCreated, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockChannelService.AssertExpectations(t)
mockMessageService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
mockUserService.AssertExpectations(t)
})
}
func TestHandler_CreateMessage_BadRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockUser := fixture.GetMockUser()
router := getAuthenticatedTestRouter(mockUser.ID)
mockMessageService := new(mocks.MessageService)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
MaxBodyBytes: 4 * 1024 * 1024,
})
testCases := []struct {
name string
body url.Values
}{
{
name: "No file nor text",
body: map[string][]string{},
},
{
name: "Text empty and no file",
body: map[string][]string{
"text": {""},
},
},
{
name: "Text too long",
body: map[string][]string{
"text": {fixture.RandStringRunes(2001)},
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
form := tc.body
request, _ := http.NewRequest(http.MethodPost, "/api/messages/"+fixture.RandID(), strings.NewReader(form.Encode()))
request.Form = form
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockMessageService.AssertNotCalled(t, "CreateMessage")
})
}
}
func TestHandler_UpdateMessage(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully updated", func(t *testing.T) {
mockMessage := fixture.GetMockMessage(authUser.ID, "")
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", mockMessage.ID).Return(mockMessage, nil)
mockMessageService.On("UpdateMessage", mockMessage).Return(nil)
response := model.MessageResponse{
Id: mockMessage.ID,
Text: mockMessage.Text,
CreatedAt: mockMessage.CreatedAt,
UpdatedAt: mockMessage.UpdatedAt,
Attachment: mockMessage.Attachment,
User: model.MemberResponse{
Id: authUser.ID,
},
}
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitEditMessage", mockMessage.ChannelId, &response).Return()
reqBody, err := json.Marshal(gin.H{
"text": *mockMessage.Text,
})
assert.NoError(t, err)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPut, "/api/messages/"+mockMessage.ID, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(true)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Message not found", func(t *testing.T) {
id := fixture.RandID()
mockError := apperrors.NewNotFound("message", id)
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", id).Return(nil, mockError)
mockSocketService := new(mocks.SocketService)
reqBody, err := json.Marshal(gin.H{
"text": fixture.RandStringRunes(12),
})
assert.NoError(t, err)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPut, "/api/messages/"+id, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertCalled(t, "Get", id)
mockMessageService.AssertNotCalled(t, "UpdateMessage")
mockSocketService.AssertNotCalled(t, "EmitEditMessage")
})
t.Run("Not the message author", func(t *testing.T) {
mockMessage := fixture.GetMockMessage("", "")
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", mockMessage.ID).Return(mockMessage, nil)
mockSocketService := new(mocks.SocketService)
reqBody, err := json.Marshal(gin.H{
"text": fixture.RandStringRunes(12),
})
assert.NoError(t, err)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPut, "/api/messages/"+mockMessage.ID, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.EditMessageError)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertCalled(t, "Get", mockMessage.ID)
mockMessageService.AssertNotCalled(t, "UpdateMessage")
mockSocketService.AssertNotCalled(t, "EmitEditMessage")
})
t.Run("Unauthorized", func(t *testing.T) {
mockMessage := fixture.GetMockMessage("", "")
mockMessageService := new(mocks.MessageService)
mockSocketService := new(mocks.SocketService)
reqBody, err := json.Marshal(gin.H{
"text": fixture.RandStringRunes(12),
})
assert.NoError(t, err)
router := getTestRouter()
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPut, "/api/messages/"+mockMessage.ID, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertNotCalled(t, "Get")
mockMessageService.AssertNotCalled(t, "UpdateMessage")
mockSocketService.AssertNotCalled(t, "EmitEditMessage")
})
t.Run("Server Error", func(t *testing.T) {
mockMessage := fixture.GetMockMessage(authUser.ID, "")
mockError := apperrors.NewInternal()
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", mockMessage.ID).Return(mockMessage, nil)
mockMessageService.On("UpdateMessage", mockMessage).Return(mockError)
mockSocketService := new(mocks.SocketService)
reqBody, err := json.Marshal(gin.H{
"text": *mockMessage.Text,
})
assert.NoError(t, err)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodPut, "/api/messages/"+mockMessage.ID, bytes.NewBuffer(reqBody))
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
}
func TestHandler_UpdateMessage_BadRequest(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
mockUser := fixture.GetMockUser()
router := getAuthenticatedTestRouter(mockUser.ID)
mockMessageService := new(mocks.MessageService)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
MaxBodyBytes: 4 * 1024 * 1024,
})
testCases := []struct {
name string
body url.Values
}{
{
name: "Text is required",
body: map[string][]string{},
},
{
name: "Text empty",
body: map[string][]string{
"text": {""},
},
},
{
name: "Text too long",
body: map[string][]string{
"text": {fixture.RandStringRunes(2001)},
},
},
}
for i := range testCases {
tc := testCases[i]
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
form := tc.body
request, _ := http.NewRequest(http.MethodPut, "/api/messages/"+fixture.RandID(), strings.NewReader(form.Encode()))
request.Form = form
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusBadRequest, rr.Code)
mockMessageService.AssertNotCalled(t, "UpdateMessage")
})
}
}
func TestHandler_Delete_Message(t *testing.T) {
// Setup
gin.SetMode(gin.TestMode)
authUser := fixture.GetMockUser()
t.Run("Successfully deleted", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockMessage := fixture.GetMockMessage(authUser.ID, mockChannel.ID)
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", mockMessage.ID).Return(mockMessage, nil)
mockMessageService.On("DeleteMessage", mockMessage).Return(nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitDeleteMessage", mockChannel.ID, mockMessage.ID)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodDelete, "/api/messages/"+mockMessage.ID, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(true)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockGuildService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Message not found", func(t *testing.T) {
id := fixture.RandID()
mockError := apperrors.NewNotFound("message", id)
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", id).Return(nil, mockError)
mockChannelService := new(mocks.ChannelService)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodDelete, "/api/messages/"+id, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertCalled(t, "Get", id)
mockMessageService.AssertNotCalled(t, "DeleteMessage")
mockChannelService.AssertNotCalled(t, "Get")
mockGuildService.AssertNotCalled(t, "GetGuild")
mockSocketService.AssertNotCalled(t, "EmitDeleteMessage")
})
t.Run("Channel not found", func(t *testing.T) {
id := fixture.RandID()
mockMessage := fixture.GetMockMessage("", id)
mockError := apperrors.NewNotFound("message", mockMessage.ID)
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", mockMessage.ID).Return(mockMessage, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", id).Return(nil, mockError)
mockGuildService := new(mocks.GuildService)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodDelete, "/api/messages/"+mockMessage.ID, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertCalled(t, "Get", mockMessage.ID)
mockChannelService.AssertCalled(t, "Get", id)
mockMessageService.AssertNotCalled(t, "DeleteMessage")
mockGuildService.AssertNotCalled(t, "GetGuild")
mockSocketService.AssertNotCalled(t, "EmitDeleteMessage")
})
t.Run("Delete in guild - guild owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild(authUser.ID)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockMessage := fixture.GetMockMessage("", mockChannel.ID)
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", mockMessage.ID).Return(mockMessage, nil)
mockMessageService.On("DeleteMessage", mockMessage).Return(nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitDeleteMessage", mockChannel.ID, mockMessage.ID)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodDelete, "/api/messages/"+mockMessage.ID, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(true)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockGuildService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Delete in guild - not the guild owner", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockMessage := fixture.GetMockMessage("", mockChannel.ID)
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", mockMessage.ID).Return(mockMessage, nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodDelete, "/api/messages/"+mockMessage.ID, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.DeleteMessageError)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertCalled(t, "Get", mockMessage.ID)
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockGuildService.AssertCalled(t, "GetGuild", mockGuild.ID)
mockMessageService.AssertNotCalled(t, "DeleteMessage")
mockSocketService.AssertNotCalled(t, "EmitDeleteMessage")
})
t.Run("Delete in DM - Author", func(t *testing.T) {
mockChannel := fixture.GetMockDMChannel()
mockMessage := fixture.GetMockMessage(authUser.ID, mockChannel.ID)
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", mockMessage.ID).Return(mockMessage, nil)
mockMessageService.On("DeleteMessage", mockMessage).Return(nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockSocketService := new(mocks.SocketService)
mockSocketService.On("EmitDeleteMessage", mockChannel.ID, mockMessage.ID)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodDelete, "/api/messages/"+mockMessage.ID, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(true)
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockSocketService.AssertExpectations(t)
})
t.Run("Delete in DM - Not the author", func(t *testing.T) {
mockChannel := fixture.GetMockDMChannel()
mockMessage := fixture.GetMockMessage("", mockChannel.ID)
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", mockMessage.ID).Return(mockMessage, nil)
mockMessageService.On("DeleteMessage", mockMessage).Return(nil)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodDelete, "/api/messages/"+mockMessage.ID, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.DeleteDMMessageError)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertCalled(t, "Get", mockMessage.ID)
mockChannelService.AssertCalled(t, "Get", mockChannel.ID)
mockMessageService.AssertNotCalled(t, "DeleteMessage")
mockSocketService.AssertNotCalled(t, "EmitDeleteMessage")
})
t.Run("Unauthorized", func(t *testing.T) {
mockMessageService := new(mocks.MessageService)
mockChannelService := new(mocks.ChannelService)
mockSocketService := new(mocks.SocketService)
router := getTestRouter()
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodDelete, "/api/messages/"+fixture.RandID(), nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
mockError := apperrors.NewAuthorization(apperrors.InvalidSession)
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, mockError.Status(), rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertNotCalled(t, "Get")
mockChannelService.AssertNotCalled(t, "Get")
mockMessageService.AssertNotCalled(t, "DeleteMessage")
mockSocketService.AssertNotCalled(t, "EmitDeleteMessage")
})
t.Run("Server Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockMessage := fixture.GetMockMessage(authUser.ID, mockChannel.ID)
mockMessageService := new(mocks.MessageService)
mockMessageService.On("Get", mockMessage.ID).Return(mockMessage, nil)
mockError := apperrors.NewInternal()
mockMessageService.On("DeleteMessage", mockMessage).Return(mockError)
mockChannelService := new(mocks.ChannelService)
mockChannelService.On("Get", mockChannel.ID).Return(mockChannel, nil)
mockGuildService := new(mocks.GuildService)
mockGuildService.On("GetGuild", mockGuild.ID).Return(mockGuild, nil)
mockSocketService := new(mocks.SocketService)
router := getAuthenticatedTestRouter(authUser.ID)
NewHandler(&Config{
R: router,
MessageService: mockMessageService,
GuildService: mockGuildService,
ChannelService: mockChannelService,
SocketService: mockSocketService,
})
// a response recorder for getting written http response
rr := httptest.NewRecorder()
// use bytes.NewBuffer to create a reader
request, err := http.NewRequest(http.MethodDelete, "/api/messages/"+mockMessage.ID, nil)
assert.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
respBody, _ := json.Marshal(gin.H{
"error": mockError,
})
assert.NoError(t, err)
router.ServeHTTP(rr, request)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
assert.Equal(t, respBody, rr.Body.Bytes())
mockMessageService.AssertExpectations(t)
mockChannelService.AssertExpectations(t)
mockGuildService.AssertExpectations(t)
mockSocketService.AssertNotCalled(t, "EmitDeleteMessage")
})
}
================================================
FILE: server/handler/middleware/auth_user.go
================================================
package middleware
import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/model/apperrors"
"log"
)
// AuthUser checks if the request contains a valid session
// and saves the session's userId in the context
func AuthUser() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
id := session.Get("userId")
if id == nil {
e := apperrors.NewAuthorization(apperrors.InvalidSession)
c.JSON(e.Status(), gin.H{
"error": e,
})
c.Abort()
return
}
userId := id.(string)
c.Set("userId", userId)
// Recreate session to extend its lifetime
session.Set("userId", id)
if err := session.Save(); err != nil {
log.Printf("Failed recreate the session: %v\n", err.Error())
}
c.Next()
}
}
================================================
FILE: server/handler/middleware/auth_user_test.go
================================================
package middleware
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/service"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestAuthUser(t *testing.T) {
gin.SetMode(gin.TestMode)
uid := service.GenerateId()
t.Run("Adds an userId to context", func(t *testing.T) {
rr := httptest.NewRecorder()
_, r := gin.CreateTestContext(rr)
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions(model.CookieName, store))
r.Use(func(c *gin.Context) {
session := sessions.Default(c)
session.Set("userId", uid)
})
var contextUserId string
r.GET("/api/accounts", AuthUser(), func(c *gin.Context) {
contextKeyVal, _ := c.Get("userId")
contextUserId = contextKeyVal.(string)
})
request, _ := http.NewRequest(http.MethodGet, "/api/accounts", http.NoBody)
r.ServeHTTP(rr, request)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, contextUserId, uid)
})
t.Run("Missing Session", func(t *testing.T) {
rr := httptest.NewRecorder()
// creates a test context and gin engine
_, r := gin.CreateTestContext(rr)
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions(model.CookieName, store))
r.GET("/api/accounts", AuthUser())
request, _ := http.NewRequest(http.MethodGet, "/api/accounts", http.NoBody)
r.ServeHTTP(rr, request)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
})
}
================================================
FILE: server/handler/middleware/timeout.go
================================================
package middleware
/*
* Inspired by Golang's TimeoutHandler: https://golang.org/src/net/http/server.go?s=101514:101582#L3212
* and gin-timeout: https://github.com/vearne/gin-timeout
*/
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/sentrionic/valkyrie/model/apperrors"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// Timeout wraps the request context with a timeout
func Timeout(timeout time.Duration, errTimeout *apperrors.Error) gin.HandlerFunc {
return func(c *gin.Context) {
// set Gin's writer as our custom writer
tw := &timeoutWriter{ResponseWriter: c.Writer, h: make(http.Header)}
c.Writer = tw
// wrap the request context with a timeout
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
// update gin request context
c.Request = c.Request.WithContext(ctx)
finished := make(chan struct{}) // to indicate handler finished
panicChan := make(chan any, 1) // used to handle panics if we can't recover
go func() {
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
c.Next() // calls subsequent middleware(s) and handler
finished <- struct{}{}
}()
select {
case <-panicChan:
// if we cannot recover from panic,
// send internal server error
e := apperrors.NewInternal()
tw.ResponseWriter.WriteHeader(e.Status())
eResp, _ := json.Marshal(gin.H{
"error": e,
})
_, _ = tw.ResponseWriter.Write(eResp)
case <-finished:
// if finished, set headers and write resp
tw.mu.Lock()
defer tw.mu.Unlock()
// map Headers from tw.Header() (written to by gin)
// to tw.ResponseWriter for response
dst := tw.ResponseWriter.Header()
for k, vv := range tw.Header() {
dst[k] = vv
}
tw.ResponseWriter.WriteHeader(tw.code)
// tw.wbuf will have been written to already when gin writes to tw.Write()
_, _ = tw.ResponseWriter.Write(tw.wbuf.Bytes())
case <-ctx.Done():
// timeout has occurred, send errTimeout and write headers
tw.mu.Lock()
defer tw.mu.Unlock()
// ResponseWriter from gin
tw.ResponseWriter.Header().Set("Content-Type", "application/json")
tw.ResponseWriter.WriteHeader(errTimeout.Status())
eResp, _ := json.Marshal(gin.H{
"error": errTimeout,
})
_, _ = tw.ResponseWriter.Write(eResp)
c.Abort()
tw.SetTimedOut()
}
}
}
// implements http.Writer, but tracks if Writer has timed out
// or has already written its header to prevent
// header and body overwrites
// also locks access to this writer to prevent race conditions
// holds the gin.ResponseWriter which we'll manually call Write()
// on in the middleware function to send response
type timeoutWriter struct {
gin.ResponseWriter
h http.Header
wbuf bytes.Buffer // The zero value for Buffer is an empty buffer ready to use.
mu sync.Mutex
timedOut bool
wroteHeader bool
code int
}
// Writes the response, but first makes sure there
// hasn't already been a timeout
// In http.ResponseWriter interface
func (tw *timeoutWriter) Write(b []byte) (int, error) {
tw.mu.Lock()
defer tw.mu.Unlock()
if tw.timedOut {
return 0, nil
}
return tw.wbuf.Write(b)
}
// WriteHeader In http.ResponseWriter interface
func (tw *timeoutWriter) WriteHeader(code int) {
checkWriteHeaderCode(code)
tw.mu.Lock()
defer tw.mu.Unlock()
// We do not write the header if we've timed out or written the header
if tw.timedOut || tw.wroteHeader {
return
}
tw.writeHeader(code)
}
// set that the header has been written
func (tw *timeoutWriter) writeHeader(code int) {
tw.wroteHeader = true
tw.code = code
}
// Header "relays" the header, h, set in struct
// In http.ResponseWriter interface
func (tw *timeoutWriter) Header() http.Header {
return tw.h
}
// SetTimedOut sets timedOut field to true
func (tw *timeoutWriter) SetTimedOut() {
tw.timedOut = true
}
func checkWriteHeaderCode(code int) {
if code < 100 || code > 999 {
panic(fmt.Sprintf("invalid WriteHeader code %v", code))
}
}
================================================
FILE: server/handler/mime_type.go
================================================
package handler
var validImageTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
}
// IsAllowedImageType determines if image is among types defined
// in map of allowed images
func isAllowedImageType(mimeType string) bool {
_, exists := validImageTypes[mimeType]
return exists
}
var validFileTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"audio/mp3": true,
"audio/wave": true,
}
// isAllowedFileType determines if the file is among types defined
// in map of allowed file types
func isAllowedFileType(mimeType string) bool {
_, exists := validFileTypes[mimeType]
return exists
}
================================================
FILE: server/handler/test_helpers.go
================================================
package handler
import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/model"
)
func getAuthenticatedTestRouter(uid string) *gin.Engine {
router := gin.Default()
store := cookie.NewStore([]byte("secret"))
router.Use(sessions.Sessions(model.CookieName, store))
router.Use(func(c *gin.Context) {
session := sessions.Default(c)
session.Set("userId", uid)
c.Set("userId", uid)
})
return router
}
func getTestRouter() *gin.Engine {
router := gin.Default()
store := cookie.NewStore([]byte("secret"))
router.Use(sessions.Sessions(model.CookieName, store))
return router
}
func getTestFieldErrorResponse(field, message string) gin.H {
return gin.H{
"errors": []model.FieldError{
{
Field: field,
Message: message,
},
},
}
}
================================================
FILE: server/injection.go
================================================
package main
import (
"fmt"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin"
cors "github.com/rs/cors/wrapper/gin"
"github.com/sentrionic/valkyrie/config"
"github.com/sentrionic/valkyrie/handler"
"github.com/sentrionic/valkyrie/handler/middleware"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/repository"
"github.com/sentrionic/valkyrie/service"
"github.com/sentrionic/valkyrie/ws"
"github.com/ulule/limiter/v3"
mgin "github.com/ulule/limiter/v3/drivers/middleware/gin"
sredis "github.com/ulule/limiter/v3/drivers/store/redis"
"log"
"net/http"
"time"
)
func inject(d *dataSources, cfg config.Config) (*gin.Engine, error) {
log.Println("Injecting data sources")
// Repository layer
userRepository := repository.NewUserRepository(d.DB)
friendRepository := repository.NewFriendRepository(d.DB)
guildRepository := repository.NewGuildRepository(d.DB)
channelRepository := repository.NewChannelRepository(d.DB)
messageRepository := repository.NewMessageRepository(d.DB)
fileRepository := repository.NewFileRepository(d.S3Session, cfg.BucketName)
redisRepository := repository.NewRedisRepository(d.RedisClient)
mailRepository := repository.NewMailRepository(cfg.GmailUser, cfg.GmailPassword, cfg.CorsOrigin)
// Service Layer
userService := service.NewUserService(&service.USConfig{
UserRepository: userRepository,
FileRepository: fileRepository,
RedisRepository: redisRepository,
MailRepository: mailRepository,
})
friendService := service.NewFriendService(&service.FSConfig{
UserRepository: userRepository,
FriendRepository: friendRepository,
})
guildService := service.NewGuildService(&service.GSConfig{
UserRepository: userRepository,
FileRepository: fileRepository,
RedisRepository: redisRepository,
GuildRepository: guildRepository,
ChannelRepository: channelRepository,
})
channelService := service.NewChannelService(&service.CSConfig{
ChannelRepository: channelRepository,
GuildRepository: guildRepository,
})
messageService := service.NewMessageService(&service.MSConfig{
MessageRepository: messageRepository,
FileRepository: fileRepository,
})
// initialize gin.Engine
router := gin.Default()
// set cors settings
c := cors.New(cors.Options{
AllowedOrigins: []string{cfg.CorsOrigin},
AllowCredentials: true,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
})
router.Use(c)
redisURL := d.RedisClient.Options().Addr
password := d.RedisClient.Options().Password
// initialize session store
store, err := redis.NewStore(10, "tcp", redisURL, password, []byte(cfg.SessionSecret))
if err != nil {
return nil, fmt.Errorf("could not initialize redis session store: %w", err)
}
store.Options(sessions.Options{
Domain: cfg.Domain,
MaxAge: 60 * 60 * 24 * 7, // 7 days
Secure: gin.Mode() == gin.ReleaseMode,
HttpOnly: true,
Path: "/",
SameSite: http.SameSiteLaxMode,
})
router.Use(sessions.Sessions(model.CookieName, store))
// add rate limit
rate := limiter.Rate{
Period: 1 * time.Hour,
Limit: 1500,
}
limitStore, _ := sredis.NewStore(d.RedisClient)
rateLimiter := mgin.NewMiddleware(limiter.New(limitStore, rate))
router.Use(rateLimiter)
// Websockets Setup
hub := ws.NewWebsocketHub(&ws.Config{
UserService: userService,
GuildService: guildService,
ChannelService: channelService,
Redis: d.RedisClient,
})
go hub.Run()
router.GET("/ws", middleware.AuthUser(), func(c *gin.Context) {
ws.ServeWs(hub, c)
})
socketService := service.NewSocketService(&service.SSConfig{
Hub: *hub,
GuildRepository: guildRepository,
ChannelRepository: channelRepository,
})
handler.NewHandler(&handler.Config{
R: router,
UserService: userService,
FriendService: friendService,
GuildService: guildService,
ChannelService: channelService,
MessageService: messageService,
SocketService: socketService,
TimeoutDuration: time.Duration(cfg.HandlerTimeOut) * time.Second,
MaxBodyBytes: cfg.MaxBodyBytes,
})
return router, nil
}
================================================
FILE: server/main.go
================================================
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/sentrionic/valkyrie/config"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
// @title Valkyrie API
// @version 1.0
// @description Valkyrie REST API Specs. This service uses sessions for authentication
// @license.name Apache 2.0
// @host localhost:
// @BasePath /api
func main() {
log.Println("Starting server...")
// Load dev env from .env file
if gin.Mode() != gin.ReleaseMode {
err := godotenv.Load()
if err != nil {
log.Fatalln("Error loading .env file")
}
}
ctx := context.Background()
cfg, err := config.LoadConfig(ctx)
if err != nil {
log.Fatalf("Could not load the config: %v\n", err)
}
// initialize data sources
ds, err := initDS(ctx, cfg)
if err != nil {
log.Fatalf("Unable to initialize data sources: %v\n", err)
}
router, err := inject(ds, cfg)
if err != nil {
log.Fatalf("Failure to inject data sources: %v\n", err)
}
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: router,
}
// Graceful server shutdown - https://github.com/gin-gonic/examples/blob/master/graceful-shutdown/graceful-shutdown/server.go
go func() {
if err = srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to initialize server: %v\n", err)
}
}()
log.Printf("Listening on port %v\n", srv.Addr)
// Wait for kill signal of channel
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// This blocks until a signal is passed into the quit channel
<-quit
// The context is used to inform the server it has 5 seconds to finish
// the request it is currently handling
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// shutdown data sources
if err := ds.close(); err != nil {
log.Fatalf("A problem occurred gracefully shutting down data sources: %v\n", err)
}
// Shutdown server
log.Println("Shutting down server...")
if err = srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v\n", err)
}
}
================================================
FILE: server/mocks/ChannelRepository.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// ChannelRepository is an autogenerated mock type for the ChannelRepository type
type ChannelRepository struct {
mock.Mock
}
// AddDMChannelMembers provides a mock function with given fields: members
func (_m *ChannelRepository) AddDMChannelMembers(members []model.DMMember) error {
ret := _m.Called(members)
var r0 error
if rf, ok := ret.Get(0).(func([]model.DMMember) error); ok {
r0 = rf(members)
} else {
r0 = ret.Error(0)
}
return r0
}
// AddPrivateChannelMembers provides a mock function with given fields: memberIds, channelId
func (_m *ChannelRepository) AddPrivateChannelMembers(memberIds []string, channelId string) error {
ret := _m.Called(memberIds, channelId)
var r0 error
if rf, ok := ret.Get(0).(func([]string, string) error); ok {
r0 = rf(memberIds, channelId)
} else {
r0 = ret.Error(0)
}
return r0
}
// CleanPCMembers provides a mock function with given fields: channelId
func (_m *ChannelRepository) CleanPCMembers(channelId string) error {
ret := _m.Called(channelId)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(channelId)
} else {
r0 = ret.Error(0)
}
return r0
}
// Create provides a mock function with given fields: channel
func (_m *ChannelRepository) Create(channel *model.Channel) (*model.Channel, error) {
ret := _m.Called(channel)
var r0 *model.Channel
if rf, ok := ret.Get(0).(func(*model.Channel) *model.Channel); ok {
r0 = rf(channel)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*model.Channel) error); ok {
r1 = rf(channel)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DeleteChannel provides a mock function with given fields: channel
func (_m *ChannelRepository) DeleteChannel(channel *model.Channel) error {
ret := _m.Called(channel)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Channel) error); ok {
r0 = rf(channel)
} else {
r0 = ret.Error(0)
}
return r0
}
// FindDMByUserAndChannelId provides a mock function with given fields: channelId, userId
func (_m *ChannelRepository) FindDMByUserAndChannelId(channelId string, userId string) (string, error) {
ret := _m.Called(channelId, userId)
var r0 string
if rf, ok := ret.Get(0).(func(string, string) string); ok {
r0 = rf(channelId, userId)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(channelId, userId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Get provides a mock function with given fields: userId, guildId
func (_m *ChannelRepository) Get(userId string, guildId string) (*[]model.ChannelResponse, error) {
ret := _m.Called(userId, guildId)
var r0 *[]model.ChannelResponse
if rf, ok := ret.Get(0).(func(string, string) *[]model.ChannelResponse); ok {
r0 = rf(userId, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.ChannelResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetById provides a mock function with given fields: channelId
func (_m *ChannelRepository) GetById(channelId string) (*model.Channel, error) {
ret := _m.Called(channelId)
var r0 *model.Channel
if rf, ok := ret.Get(0).(func(string) *model.Channel); ok {
r0 = rf(channelId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(channelId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetDMMemberIds provides a mock function with given fields: channelId
func (_m *ChannelRepository) GetDMMemberIds(channelId string) (*[]string, error) {
ret := _m.Called(channelId)
var r0 *[]string
if rf, ok := ret.Get(0).(func(string) *[]string); ok {
r0 = rf(channelId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(channelId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetDirectMessageChannel provides a mock function with given fields: userId, memberId
func (_m *ChannelRepository) GetDirectMessageChannel(userId string, memberId string) (*string, error) {
ret := _m.Called(userId, memberId)
var r0 *string
if rf, ok := ret.Get(0).(func(string, string) *string); ok {
r0 = rf(userId, memberId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, memberId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetDirectMessages provides a mock function with given fields: userId
func (_m *ChannelRepository) GetDirectMessages(userId string) (*[]model.DirectMessage, error) {
ret := _m.Called(userId)
var r0 *[]model.DirectMessage
if rf, ok := ret.Get(0).(func(string) *[]model.DirectMessage); ok {
r0 = rf(userId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.DirectMessage)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(userId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetGuildDefault provides a mock function with given fields: guildId
func (_m *ChannelRepository) GetGuildDefault(guildId string) (*model.Channel, error) {
ret := _m.Called(guildId)
var r0 *model.Channel
if rf, ok := ret.Get(0).(func(string) *model.Channel); ok {
r0 = rf(guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetPrivateChannelMembers provides a mock function with given fields: channelId
func (_m *ChannelRepository) GetPrivateChannelMembers(channelId string) (*[]string, error) {
ret := _m.Called(channelId)
var r0 *[]string
if rf, ok := ret.Get(0).(func(string) *[]string); ok {
r0 = rf(channelId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(channelId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// OpenDMForAll provides a mock function with given fields: dmId
func (_m *ChannelRepository) OpenDMForAll(dmId string) error {
ret := _m.Called(dmId)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(dmId)
} else {
r0 = ret.Error(0)
}
return r0
}
// RemovePrivateChannelMembers provides a mock function with given fields: memberIds, channelId
func (_m *ChannelRepository) RemovePrivateChannelMembers(memberIds []string, channelId string) error {
ret := _m.Called(memberIds, channelId)
var r0 error
if rf, ok := ret.Get(0).(func([]string, string) error); ok {
r0 = rf(memberIds, channelId)
} else {
r0 = ret.Error(0)
}
return r0
}
// SetDirectMessageStatus provides a mock function with given fields: dmId, userId, isOpen
func (_m *ChannelRepository) SetDirectMessageStatus(dmId string, userId string, isOpen bool) error {
ret := _m.Called(dmId, userId, isOpen)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, bool) error); ok {
r0 = rf(dmId, userId, isOpen)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateChannel provides a mock function with given fields: channel
func (_m *ChannelRepository) UpdateChannel(channel *model.Channel) error {
ret := _m.Called(channel)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Channel) error); ok {
r0 = rf(channel)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewChannelRepository creates a new instance of ChannelRepository. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewChannelRepository(t testing.TB) *ChannelRepository {
mock := &ChannelRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/ChannelService.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// ChannelService is an autogenerated mock type for the ChannelService type
type ChannelService struct {
mock.Mock
}
// AddDMChannelMembers provides a mock function with given fields: memberIds, channelId, userId
func (_m *ChannelService) AddDMChannelMembers(memberIds []string, channelId string, userId string) error {
ret := _m.Called(memberIds, channelId, userId)
var r0 error
if rf, ok := ret.Get(0).(func([]string, string, string) error); ok {
r0 = rf(memberIds, channelId, userId)
} else {
r0 = ret.Error(0)
}
return r0
}
// AddPrivateChannelMembers provides a mock function with given fields: memberIds, channelId
func (_m *ChannelService) AddPrivateChannelMembers(memberIds []string, channelId string) error {
ret := _m.Called(memberIds, channelId)
var r0 error
if rf, ok := ret.Get(0).(func([]string, string) error); ok {
r0 = rf(memberIds, channelId)
} else {
r0 = ret.Error(0)
}
return r0
}
// CleanPCMembers provides a mock function with given fields: channelId
func (_m *ChannelService) CleanPCMembers(channelId string) error {
ret := _m.Called(channelId)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(channelId)
} else {
r0 = ret.Error(0)
}
return r0
}
// CreateChannel provides a mock function with given fields: channel
func (_m *ChannelService) CreateChannel(channel *model.Channel) (*model.Channel, error) {
ret := _m.Called(channel)
var r0 *model.Channel
if rf, ok := ret.Get(0).(func(*model.Channel) *model.Channel); ok {
r0 = rf(channel)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*model.Channel) error); ok {
r1 = rf(channel)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DeleteChannel provides a mock function with given fields: channel
func (_m *ChannelService) DeleteChannel(channel *model.Channel) error {
ret := _m.Called(channel)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Channel) error); ok {
r0 = rf(channel)
} else {
r0 = ret.Error(0)
}
return r0
}
// Get provides a mock function with given fields: channelId
func (_m *ChannelService) Get(channelId string) (*model.Channel, error) {
ret := _m.Called(channelId)
var r0 *model.Channel
if rf, ok := ret.Get(0).(func(string) *model.Channel); ok {
r0 = rf(channelId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(channelId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetChannels provides a mock function with given fields: userId, guildId
func (_m *ChannelService) GetChannels(userId string, guildId string) (*[]model.ChannelResponse, error) {
ret := _m.Called(userId, guildId)
var r0 *[]model.ChannelResponse
if rf, ok := ret.Get(0).(func(string, string) *[]model.ChannelResponse); ok {
r0 = rf(userId, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.ChannelResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetDMByUserAndChannel provides a mock function with given fields: userId, channelId
func (_m *ChannelService) GetDMByUserAndChannel(userId string, channelId string) (string, error) {
ret := _m.Called(userId, channelId)
var r0 string
if rf, ok := ret.Get(0).(func(string, string) string); ok {
r0 = rf(userId, channelId)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, channelId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetDirectMessageChannel provides a mock function with given fields: userId, memberId
func (_m *ChannelService) GetDirectMessageChannel(userId string, memberId string) (*string, error) {
ret := _m.Called(userId, memberId)
var r0 *string
if rf, ok := ret.Get(0).(func(string, string) *string); ok {
r0 = rf(userId, memberId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, memberId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetDirectMessages provides a mock function with given fields: userId
func (_m *ChannelService) GetDirectMessages(userId string) (*[]model.DirectMessage, error) {
ret := _m.Called(userId)
var r0 *[]model.DirectMessage
if rf, ok := ret.Get(0).(func(string) *[]model.DirectMessage); ok {
r0 = rf(userId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.DirectMessage)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(userId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetPrivateChannelMembers provides a mock function with given fields: channelId
func (_m *ChannelService) GetPrivateChannelMembers(channelId string) (*[]string, error) {
ret := _m.Called(channelId)
var r0 *[]string
if rf, ok := ret.Get(0).(func(string) *[]string); ok {
r0 = rf(channelId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(channelId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// IsChannelMember provides a mock function with given fields: channel, userId
func (_m *ChannelService) IsChannelMember(channel *model.Channel, userId string) error {
ret := _m.Called(channel, userId)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Channel, string) error); ok {
r0 = rf(channel, userId)
} else {
r0 = ret.Error(0)
}
return r0
}
// OpenDMForAll provides a mock function with given fields: dmId
func (_m *ChannelService) OpenDMForAll(dmId string) error {
ret := _m.Called(dmId)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(dmId)
} else {
r0 = ret.Error(0)
}
return r0
}
// RemovePrivateChannelMembers provides a mock function with given fields: memberIds, channelId
func (_m *ChannelService) RemovePrivateChannelMembers(memberIds []string, channelId string) error {
ret := _m.Called(memberIds, channelId)
var r0 error
if rf, ok := ret.Get(0).(func([]string, string) error); ok {
r0 = rf(memberIds, channelId)
} else {
r0 = ret.Error(0)
}
return r0
}
// SetDirectMessageStatus provides a mock function with given fields: dmId, userId, isOpen
func (_m *ChannelService) SetDirectMessageStatus(dmId string, userId string, isOpen bool) error {
ret := _m.Called(dmId, userId, isOpen)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, bool) error); ok {
r0 = rf(dmId, userId, isOpen)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateChannel provides a mock function with given fields: channel
func (_m *ChannelService) UpdateChannel(channel *model.Channel) error {
ret := _m.Called(channel)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Channel) error); ok {
r0 = rf(channel)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewChannelService creates a new instance of ChannelService. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewChannelService(t testing.TB) *ChannelService {
mock := &ChannelService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/FileRepository.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
mock "github.com/stretchr/testify/mock"
multipart "mime/multipart"
testing "testing"
)
// FileRepository is an autogenerated mock type for the FileRepository type
type FileRepository struct {
mock.Mock
}
// DeleteImage provides a mock function with given fields: key
func (_m *FileRepository) DeleteImage(key string) error {
ret := _m.Called(key)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(key)
} else {
r0 = ret.Error(0)
}
return r0
}
// UploadAvatar provides a mock function with given fields: header, directory
func (_m *FileRepository) UploadAvatar(header *multipart.FileHeader, directory string) (string, error) {
ret := _m.Called(header, directory)
var r0 string
if rf, ok := ret.Get(0).(func(*multipart.FileHeader, string) string); ok {
r0 = rf(header, directory)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(*multipart.FileHeader, string) error); ok {
r1 = rf(header, directory)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UploadFile provides a mock function with given fields: header, directory, filename, mimetype
func (_m *FileRepository) UploadFile(header *multipart.FileHeader, directory string, filename string, mimetype string) (string, error) {
ret := _m.Called(header, directory, filename, mimetype)
var r0 string
if rf, ok := ret.Get(0).(func(*multipart.FileHeader, string, string, string) string); ok {
r0 = rf(header, directory, filename, mimetype)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(*multipart.FileHeader, string, string, string) error); ok {
r1 = rf(header, directory, filename, mimetype)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewFileRepository creates a new instance of FileRepository. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewFileRepository(t testing.TB) *FileRepository {
mock := &FileRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/FriendRepository.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// FriendRepository is an autogenerated mock type for the FriendRepository type
type FriendRepository struct {
mock.Mock
}
// DeleteRequest provides a mock function with given fields: memberId, userId
func (_m *FriendRepository) DeleteRequest(memberId string, userId string) error {
ret := _m.Called(memberId, userId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(memberId, userId)
} else {
r0 = ret.Error(0)
}
return r0
}
// FindByID provides a mock function with given fields: id
func (_m *FriendRepository) FindByID(id string) (*model.User, error) {
ret := _m.Called(id)
var r0 *model.User
if rf, ok := ret.Get(0).(func(string) *model.User); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FriendsList provides a mock function with given fields: id
func (_m *FriendRepository) FriendsList(id string) (*[]model.Friend, error) {
ret := _m.Called(id)
var r0 *[]model.Friend
if rf, ok := ret.Get(0).(func(string) *[]model.Friend); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.Friend)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RemoveFriend provides a mock function with given fields: memberId, userId
func (_m *FriendRepository) RemoveFriend(memberId string, userId string) error {
ret := _m.Called(memberId, userId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(memberId, userId)
} else {
r0 = ret.Error(0)
}
return r0
}
// RequestList provides a mock function with given fields: id
func (_m *FriendRepository) RequestList(id string) (*[]model.FriendRequest, error) {
ret := _m.Called(id)
var r0 *[]model.FriendRequest
if rf, ok := ret.Get(0).(func(string) *[]model.FriendRequest); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.FriendRequest)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Save provides a mock function with given fields: user
func (_m *FriendRepository) Save(user *model.User) error {
ret := _m.Called(user)
var r0 error
if rf, ok := ret.Get(0).(func(*model.User) error); ok {
r0 = rf(user)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewFriendRepository creates a new instance of FriendRepository. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewFriendRepository(t testing.TB) *FriendRepository {
mock := &FriendRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/FriendService.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// FriendService is an autogenerated mock type for the FriendService type
type FriendService struct {
mock.Mock
}
// DeleteRequest provides a mock function with given fields: memberId, userId
func (_m *FriendService) DeleteRequest(memberId string, userId string) error {
ret := _m.Called(memberId, userId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(memberId, userId)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetFriends provides a mock function with given fields: id
func (_m *FriendService) GetFriends(id string) (*[]model.Friend, error) {
ret := _m.Called(id)
var r0 *[]model.Friend
if rf, ok := ret.Get(0).(func(string) *[]model.Friend); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.Friend)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMemberById provides a mock function with given fields: id
func (_m *FriendService) GetMemberById(id string) (*model.User, error) {
ret := _m.Called(id)
var r0 *model.User
if rf, ok := ret.Get(0).(func(string) *model.User); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetRequests provides a mock function with given fields: id
func (_m *FriendService) GetRequests(id string) (*[]model.FriendRequest, error) {
ret := _m.Called(id)
var r0 *[]model.FriendRequest
if rf, ok := ret.Get(0).(func(string) *[]model.FriendRequest); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.FriendRequest)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RemoveFriend provides a mock function with given fields: memberId, userId
func (_m *FriendService) RemoveFriend(memberId string, userId string) error {
ret := _m.Called(memberId, userId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(memberId, userId)
} else {
r0 = ret.Error(0)
}
return r0
}
// SaveRequests provides a mock function with given fields: user
func (_m *FriendService) SaveRequests(user *model.User) error {
ret := _m.Called(user)
var r0 error
if rf, ok := ret.Get(0).(func(*model.User) error); ok {
r0 = rf(user)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewFriendService creates a new instance of FriendService. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewFriendService(t testing.TB) *FriendService {
mock := &FriendService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/GuildRepository.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// GuildRepository is an autogenerated mock type for the GuildRepository type
type GuildRepository struct {
mock.Mock
}
// Create provides a mock function with given fields: guild
func (_m *GuildRepository) Create(guild *model.Guild) (*model.Guild, error) {
ret := _m.Called(guild)
var r0 *model.Guild
if rf, ok := ret.Get(0).(func(*model.Guild) *model.Guild); ok {
r0 = rf(guild)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Guild)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*model.Guild) error); ok {
r1 = rf(guild)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Delete provides a mock function with given fields: guildId
func (_m *GuildRepository) Delete(guildId string) error {
ret := _m.Called(guildId)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// FindByID provides a mock function with given fields: id
func (_m *GuildRepository) FindByID(id string) (*model.Guild, error) {
ret := _m.Called(id)
var r0 *model.Guild
if rf, ok := ret.Get(0).(func(string) *model.Guild); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Guild)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindUserByID provides a mock function with given fields: uid
func (_m *GuildRepository) FindUserByID(uid string) (*model.User, error) {
ret := _m.Called(uid)
var r0 *model.User
if rf, ok := ret.Get(0).(func(string) *model.User); ok {
r0 = rf(uid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(uid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindUsersByIds provides a mock function with given fields: ids, guildId
func (_m *GuildRepository) FindUsersByIds(ids []string, guildId string) (*[]model.User, error) {
ret := _m.Called(ids, guildId)
var r0 *[]model.User
if rf, ok := ret.Get(0).(func([]string, string) *[]model.User); ok {
r0 = rf(ids, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func([]string, string) error); ok {
r1 = rf(ids, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetBanList provides a mock function with given fields: guildId
func (_m *GuildRepository) GetBanList(guildId string) (*[]model.BanResponse, error) {
ret := _m.Called(guildId)
var r0 *[]model.BanResponse
if rf, ok := ret.Get(0).(func(string) *[]model.BanResponse); ok {
r0 = rf(guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.BanResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMember provides a mock function with given fields: userId, guildId
func (_m *GuildRepository) GetMember(userId string, guildId string) (*model.User, error) {
ret := _m.Called(userId, guildId)
var r0 *model.User
if rf, ok := ret.Get(0).(func(string, string) *model.User); ok {
r0 = rf(userId, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMemberIds provides a mock function with given fields: guildId
func (_m *GuildRepository) GetMemberIds(guildId string) (*[]string, error) {
ret := _m.Called(guildId)
var r0 *[]string
if rf, ok := ret.Get(0).(func(string) *[]string); ok {
r0 = rf(guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMemberSettings provides a mock function with given fields: userId, guildId
func (_m *GuildRepository) GetMemberSettings(userId string, guildId string) (*model.MemberSettings, error) {
ret := _m.Called(userId, guildId)
var r0 *model.MemberSettings
if rf, ok := ret.Get(0).(func(string, string) *model.MemberSettings); ok {
r0 = rf(userId, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.MemberSettings)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetVCMember provides a mock function with given fields: userId, guildId
func (_m *GuildRepository) GetVCMember(userId string, guildId string) (*model.VCMember, error) {
ret := _m.Called(userId, guildId)
var r0 *model.VCMember
if rf, ok := ret.Get(0).(func(string, string) *model.VCMember); ok {
r0 = rf(userId, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.VCMember)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GuildMembers provides a mock function with given fields: userId, guildId
func (_m *GuildRepository) GuildMembers(userId string, guildId string) (*[]model.MemberResponse, error) {
ret := _m.Called(userId, guildId)
var r0 *[]model.MemberResponse
if rf, ok := ret.Get(0).(func(string, string) *[]model.MemberResponse); ok {
r0 = rf(userId, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.MemberResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// List provides a mock function with given fields: uid
func (_m *GuildRepository) List(uid string) (*[]model.GuildResponse, error) {
ret := _m.Called(uid)
var r0 *[]model.GuildResponse
if rf, ok := ret.Get(0).(func(string) *[]model.GuildResponse); ok {
r0 = rf(uid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.GuildResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(uid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RemoveMember provides a mock function with given fields: userId, guildId
func (_m *GuildRepository) RemoveMember(userId string, guildId string) error {
ret := _m.Called(userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// RemoveVCMember provides a mock function with given fields: userId, guildId
func (_m *GuildRepository) RemoveVCMember(userId string, guildId string) error {
ret := _m.Called(userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// Save provides a mock function with given fields: guild
func (_m *GuildRepository) Save(guild *model.Guild) error {
ret := _m.Called(guild)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Guild) error); ok {
r0 = rf(guild)
} else {
r0 = ret.Error(0)
}
return r0
}
// UnbanMember provides a mock function with given fields: userId, guildId
func (_m *GuildRepository) UnbanMember(userId string, guildId string) error {
ret := _m.Called(userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateMemberLastSeen provides a mock function with given fields: userId, guildId
func (_m *GuildRepository) UpdateMemberLastSeen(userId string, guildId string) error {
ret := _m.Called(userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateMemberSettings provides a mock function with given fields: settings, userId, guildId
func (_m *GuildRepository) UpdateMemberSettings(settings *model.MemberSettings, userId string, guildId string) error {
ret := _m.Called(settings, userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(*model.MemberSettings, string, string) error); ok {
r0 = rf(settings, userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateVCMember provides a mock function with given fields: isMuted, isDeafened, userId, guildId
func (_m *GuildRepository) UpdateVCMember(isMuted bool, isDeafened bool, userId string, guildId string) error {
ret := _m.Called(isMuted, isDeafened, userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(bool, bool, string, string) error); ok {
r0 = rf(isMuted, isDeafened, userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// VCMembers provides a mock function with given fields: guildId
func (_m *GuildRepository) VCMembers(guildId string) (*[]model.VCMemberResponse, error) {
ret := _m.Called(guildId)
var r0 *[]model.VCMemberResponse
if rf, ok := ret.Get(0).(func(string) *[]model.VCMemberResponse); ok {
r0 = rf(guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.VCMemberResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewGuildRepository creates a new instance of GuildRepository. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewGuildRepository(t testing.TB) *GuildRepository {
mock := &GuildRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/GuildService.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
context "context"
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// GuildService is an autogenerated mock type for the GuildService type
type GuildService struct {
mock.Mock
}
// CreateGuild provides a mock function with given fields: guild
func (_m *GuildService) CreateGuild(guild *model.Guild) (*model.Guild, error) {
ret := _m.Called(guild)
var r0 *model.Guild
if rf, ok := ret.Get(0).(func(*model.Guild) *model.Guild); ok {
r0 = rf(guild)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Guild)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*model.Guild) error); ok {
r1 = rf(guild)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DeleteGuild provides a mock function with given fields: guildId
func (_m *GuildService) DeleteGuild(guildId string) error {
ret := _m.Called(guildId)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// FindUsersByIds provides a mock function with given fields: ids, guildId
func (_m *GuildService) FindUsersByIds(ids []string, guildId string) (*[]model.User, error) {
ret := _m.Called(ids, guildId)
var r0 *[]model.User
if rf, ok := ret.Get(0).(func([]string, string) *[]model.User); ok {
r0 = rf(ids, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func([]string, string) error); ok {
r1 = rf(ids, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GenerateInviteLink provides a mock function with given fields: ctx, guildId, isPermanent
func (_m *GuildService) GenerateInviteLink(ctx context.Context, guildId string, isPermanent bool) (string, error) {
ret := _m.Called(ctx, guildId, isPermanent)
var r0 string
if rf, ok := ret.Get(0).(func(context.Context, string, bool) string); ok {
r0 = rf(ctx, guildId, isPermanent)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, guildId, isPermanent)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetBanList provides a mock function with given fields: guildId
func (_m *GuildService) GetBanList(guildId string) (*[]model.BanResponse, error) {
ret := _m.Called(guildId)
var r0 *[]model.BanResponse
if rf, ok := ret.Get(0).(func(string) *[]model.BanResponse); ok {
r0 = rf(guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.BanResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetDefaultChannel provides a mock function with given fields: guildId
func (_m *GuildService) GetDefaultChannel(guildId string) (*model.Channel, error) {
ret := _m.Called(guildId)
var r0 *model.Channel
if rf, ok := ret.Get(0).(func(string) *model.Channel); ok {
r0 = rf(guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetGuild provides a mock function with given fields: id
func (_m *GuildService) GetGuild(id string) (*model.Guild, error) {
ret := _m.Called(id)
var r0 *model.Guild
if rf, ok := ret.Get(0).(func(string) *model.Guild); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Guild)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetGuildIdFromInvite provides a mock function with given fields: ctx, token
func (_m *GuildService) GetGuildIdFromInvite(ctx context.Context, token string) (string, error) {
ret := _m.Called(ctx, token)
var r0 string
if rf, ok := ret.Get(0).(func(context.Context, string) string); ok {
r0 = rf(ctx, token)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, token)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetGuildMembers provides a mock function with given fields: userId, guildId
func (_m *GuildService) GetGuildMembers(userId string, guildId string) (*[]model.MemberResponse, error) {
ret := _m.Called(userId, guildId)
var r0 *[]model.MemberResponse
if rf, ok := ret.Get(0).(func(string, string) *[]model.MemberResponse); ok {
r0 = rf(userId, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.MemberResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMemberSettings provides a mock function with given fields: userId, guildId
func (_m *GuildService) GetMemberSettings(userId string, guildId string) (*model.MemberSettings, error) {
ret := _m.Called(userId, guildId)
var r0 *model.MemberSettings
if rf, ok := ret.Get(0).(func(string, string) *model.MemberSettings); ok {
r0 = rf(userId, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.MemberSettings)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUser provides a mock function with given fields: uid
func (_m *GuildService) GetUser(uid string) (*model.User, error) {
ret := _m.Called(uid)
var r0 *model.User
if rf, ok := ret.Get(0).(func(string) *model.User); ok {
r0 = rf(uid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(uid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUserGuilds provides a mock function with given fields: uid
func (_m *GuildService) GetUserGuilds(uid string) (*[]model.GuildResponse, error) {
ret := _m.Called(uid)
var r0 *[]model.GuildResponse
if rf, ok := ret.Get(0).(func(string) *[]model.GuildResponse); ok {
r0 = rf(uid)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.GuildResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(uid)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetVCMember provides a mock function with given fields: userId, guildId
func (_m *GuildService) GetVCMember(userId string, guildId string) (*model.VCMember, error) {
ret := _m.Called(userId, guildId)
var r0 *model.VCMember
if rf, ok := ret.Get(0).(func(string, string) *model.VCMember); ok {
r0 = rf(userId, guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.VCMember)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userId, guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetVCMembers provides a mock function with given fields: guildId
func (_m *GuildService) GetVCMembers(guildId string) (*[]model.VCMemberResponse, error) {
ret := _m.Called(guildId)
var r0 *[]model.VCMemberResponse
if rf, ok := ret.Get(0).(func(string) *[]model.VCMemberResponse); ok {
r0 = rf(guildId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.VCMemberResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(guildId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// InvalidateInvites provides a mock function with given fields: ctx, guild
func (_m *GuildService) InvalidateInvites(ctx context.Context, guild *model.Guild) {
_m.Called(ctx, guild)
}
// RemoveMember provides a mock function with given fields: userId, guildId
func (_m *GuildService) RemoveMember(userId string, guildId string) error {
ret := _m.Called(userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// RemoveVCMember provides a mock function with given fields: userId, guildId
func (_m *GuildService) RemoveVCMember(userId string, guildId string) error {
ret := _m.Called(userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// UnbanMember provides a mock function with given fields: userId, guildId
func (_m *GuildService) UnbanMember(userId string, guildId string) error {
ret := _m.Called(userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateGuild provides a mock function with given fields: guild
func (_m *GuildService) UpdateGuild(guild *model.Guild) error {
ret := _m.Called(guild)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Guild) error); ok {
r0 = rf(guild)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateMemberLastSeen provides a mock function with given fields: userId, guildId
func (_m *GuildService) UpdateMemberLastSeen(userId string, guildId string) error {
ret := _m.Called(userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateMemberSettings provides a mock function with given fields: settings, userId, guildId
func (_m *GuildService) UpdateMemberSettings(settings *model.MemberSettings, userId string, guildId string) error {
ret := _m.Called(settings, userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(*model.MemberSettings, string, string) error); ok {
r0 = rf(settings, userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateVCMember provides a mock function with given fields: isMuted, isDeafened, userId, guildId
func (_m *GuildService) UpdateVCMember(isMuted bool, isDeafened bool, userId string, guildId string) error {
ret := _m.Called(isMuted, isDeafened, userId, guildId)
var r0 error
if rf, ok := ret.Get(0).(func(bool, bool, string, string) error); ok {
r0 = rf(isMuted, isDeafened, userId, guildId)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewGuildService creates a new instance of GuildService. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewGuildService(t testing.TB) *GuildService {
mock := &GuildService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/MailRepository.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// MailRepository is an autogenerated mock type for the MailRepository type
type MailRepository struct {
mock.Mock
}
// SendResetMail provides a mock function with given fields: email, html
func (_m *MailRepository) SendResetMail(email string, html string) error {
ret := _m.Called(email, html)
var r0 error
if rf, ok := ret.Get(0).(func(string, string) error); ok {
r0 = rf(email, html)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewMailRepository creates a new instance of MailRepository. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewMailRepository(t testing.TB) *MailRepository {
mock := &MailRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/MessageRepository.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// MessageRepository is an autogenerated mock type for the MessageRepository type
type MessageRepository struct {
mock.Mock
}
// CreateMessage provides a mock function with given fields: params
func (_m *MessageRepository) CreateMessage(params *model.Message) (*model.Message, error) {
ret := _m.Called(params)
var r0 *model.Message
if rf, ok := ret.Get(0).(func(*model.Message) *model.Message); ok {
r0 = rf(params)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Message)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*model.Message) error); ok {
r1 = rf(params)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DeleteMessage provides a mock function with given fields: message
func (_m *MessageRepository) DeleteMessage(message *model.Message) error {
ret := _m.Called(message)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Message) error); ok {
r0 = rf(message)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetById provides a mock function with given fields: messageId
func (_m *MessageRepository) GetById(messageId string) (*model.Message, error) {
ret := _m.Called(messageId)
var r0 *model.Message
if rf, ok := ret.Get(0).(func(string) *model.Message); ok {
r0 = rf(messageId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Message)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(messageId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMessages provides a mock function with given fields: userId, channel, cursor
func (_m *MessageRepository) GetMessages(userId string, channel *model.Channel, cursor string) (*[]model.MessageResponse, error) {
ret := _m.Called(userId, channel, cursor)
var r0 *[]model.MessageResponse
if rf, ok := ret.Get(0).(func(string, *model.Channel, string) *[]model.MessageResponse); ok {
r0 = rf(userId, channel, cursor)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.MessageResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, *model.Channel, string) error); ok {
r1 = rf(userId, channel, cursor)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateMessage provides a mock function with given fields: message
func (_m *MessageRepository) UpdateMessage(message *model.Message) error {
ret := _m.Called(message)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Message) error); ok {
r0 = rf(message)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewMessageRepository creates a new instance of MessageRepository. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewMessageRepository(t testing.TB) *MessageRepository {
mock := &MessageRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/MessageService.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
multipart "mime/multipart"
testing "testing"
)
// MessageService is an autogenerated mock type for the MessageService type
type MessageService struct {
mock.Mock
}
// CreateMessage provides a mock function with given fields: params
func (_m *MessageService) CreateMessage(params *model.Message) (*model.Message, error) {
ret := _m.Called(params)
var r0 *model.Message
if rf, ok := ret.Get(0).(func(*model.Message) *model.Message); ok {
r0 = rf(params)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Message)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*model.Message) error); ok {
r1 = rf(params)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DeleteMessage provides a mock function with given fields: message
func (_m *MessageService) DeleteMessage(message *model.Message) error {
ret := _m.Called(message)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Message) error); ok {
r0 = rf(message)
} else {
r0 = ret.Error(0)
}
return r0
}
// Get provides a mock function with given fields: messageId
func (_m *MessageService) Get(messageId string) (*model.Message, error) {
ret := _m.Called(messageId)
var r0 *model.Message
if rf, ok := ret.Get(0).(func(string) *model.Message); ok {
r0 = rf(messageId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Message)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(messageId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMessages provides a mock function with given fields: userId, channel, cursor
func (_m *MessageService) GetMessages(userId string, channel *model.Channel, cursor string) (*[]model.MessageResponse, error) {
ret := _m.Called(userId, channel, cursor)
var r0 *[]model.MessageResponse
if rf, ok := ret.Get(0).(func(string, *model.Channel, string) *[]model.MessageResponse); ok {
r0 = rf(userId, channel, cursor)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]model.MessageResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, *model.Channel, string) error); ok {
r1 = rf(userId, channel, cursor)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateMessage provides a mock function with given fields: message
func (_m *MessageService) UpdateMessage(message *model.Message) error {
ret := _m.Called(message)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Message) error); ok {
r0 = rf(message)
} else {
r0 = ret.Error(0)
}
return r0
}
// UploadFile provides a mock function with given fields: header, channelId
func (_m *MessageService) UploadFile(header *multipart.FileHeader, channelId string) (*model.Attachment, error) {
ret := _m.Called(header, channelId)
var r0 *model.Attachment
if rf, ok := ret.Get(0).(func(*multipart.FileHeader, string) *model.Attachment); ok {
r0 = rf(header, channelId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Attachment)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*multipart.FileHeader, string) error); ok {
r1 = rf(header, channelId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewMessageService creates a new instance of MessageService. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewMessageService(t testing.TB) *MessageService {
mock := &MessageService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/RedisRepository.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
context "context"
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// RedisRepository is an autogenerated mock type for the RedisRepository type
type RedisRepository struct {
mock.Mock
}
// GetIdFromToken provides a mock function with given fields: ctx, token
func (_m *RedisRepository) GetIdFromToken(ctx context.Context, token string) (string, error) {
ret := _m.Called(ctx, token)
var r0 string
if rf, ok := ret.Get(0).(func(context.Context, string) string); ok {
r0 = rf(ctx, token)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, token)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetInvite provides a mock function with given fields: ctx, token
func (_m *RedisRepository) GetInvite(ctx context.Context, token string) (string, error) {
ret := _m.Called(ctx, token)
var r0 string
if rf, ok := ret.Get(0).(func(context.Context, string) string); ok {
r0 = rf(ctx, token)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, token)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// InvalidateInvites provides a mock function with given fields: ctx, guild
func (_m *RedisRepository) InvalidateInvites(ctx context.Context, guild *model.Guild) {
_m.Called(ctx, guild)
}
// SaveInvite provides a mock function with given fields: ctx, guildId, id, isPermanent
func (_m *RedisRepository) SaveInvite(ctx context.Context, guildId string, id string, isPermanent bool) error {
ret := _m.Called(ctx, guildId, id, isPermanent)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, bool) error); ok {
r0 = rf(ctx, guildId, id, isPermanent)
} else {
r0 = ret.Error(0)
}
return r0
}
// SetResetToken provides a mock function with given fields: ctx, id
func (_m *RedisRepository) SetResetToken(ctx context.Context, id string) (string, error) {
ret := _m.Called(ctx, id)
var r0 string
if rf, ok := ret.Get(0).(func(context.Context, string) string); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewRedisRepository creates a new instance of RedisRepository. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewRedisRepository(t testing.TB) *RedisRepository {
mock := &RedisRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/Request.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
testing "testing"
mock "github.com/stretchr/testify/mock"
)
// Request is an autogenerated mock type for the Request type
type Request struct {
mock.Mock
}
// validate provides a mock function with given fields:
func (_m *Request) validate() error {
ret := _m.Called()
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// NewRequest creates a new instance of Request. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewRequest(t testing.TB) *Request {
mock := &Request{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/SocketService.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// SocketService is an autogenerated mock type for the SocketService type
type SocketService struct {
mock.Mock
}
// EmitAddFriend provides a mock function with given fields: user, member
func (_m *SocketService) EmitAddFriend(user *model.User, member *model.User) {
_m.Called(user, member)
}
// EmitAddFriendRequest provides a mock function with given fields: room, request
func (_m *SocketService) EmitAddFriendRequest(room string, request *model.FriendRequest) {
_m.Called(room, request)
}
// EmitAddMember provides a mock function with given fields: room, member
func (_m *SocketService) EmitAddMember(room string, member *model.User) {
_m.Called(room, member)
}
// EmitDeleteChannel provides a mock function with given fields: channel
func (_m *SocketService) EmitDeleteChannel(channel *model.Channel) {
_m.Called(channel)
}
// EmitDeleteGuild provides a mock function with given fields: guildId, members
func (_m *SocketService) EmitDeleteGuild(guildId string, members []string) {
_m.Called(guildId, members)
}
// EmitDeleteMessage provides a mock function with given fields: room, messageId
func (_m *SocketService) EmitDeleteMessage(room string, messageId string) {
_m.Called(room, messageId)
}
// EmitEditChannel provides a mock function with given fields: room, channel
func (_m *SocketService) EmitEditChannel(room string, channel *model.ChannelResponse) {
_m.Called(room, channel)
}
// EmitEditGuild provides a mock function with given fields: guild
func (_m *SocketService) EmitEditGuild(guild *model.Guild) {
_m.Called(guild)
}
// EmitEditMessage provides a mock function with given fields: room, message
func (_m *SocketService) EmitEditMessage(room string, message *model.MessageResponse) {
_m.Called(room, message)
}
// EmitNewChannel provides a mock function with given fields: room, channel
func (_m *SocketService) EmitNewChannel(room string, channel *model.ChannelResponse) {
_m.Called(room, channel)
}
// EmitNewDMNotification provides a mock function with given fields: channelId, user
func (_m *SocketService) EmitNewDMNotification(channelId string, user *model.User) {
_m.Called(channelId, user)
}
// EmitNewMessage provides a mock function with given fields: room, message
func (_m *SocketService) EmitNewMessage(room string, message *model.MessageResponse) {
_m.Called(room, message)
}
// EmitNewNotification provides a mock function with given fields: guildId, channelId
func (_m *SocketService) EmitNewNotification(guildId string, channelId string) {
_m.Called(guildId, channelId)
}
// EmitNewPrivateChannel provides a mock function with given fields: members, channel
func (_m *SocketService) EmitNewPrivateChannel(members []string, channel *model.ChannelResponse) {
_m.Called(members, channel)
}
// EmitRemoveFriend provides a mock function with given fields: userId, memberId
func (_m *SocketService) EmitRemoveFriend(userId string, memberId string) {
_m.Called(userId, memberId)
}
// EmitRemoveFromGuild provides a mock function with given fields: memberId, guildId
func (_m *SocketService) EmitRemoveFromGuild(memberId string, guildId string) {
_m.Called(memberId, guildId)
}
// EmitRemoveMember provides a mock function with given fields: room, memberId
func (_m *SocketService) EmitRemoveMember(room string, memberId string) {
_m.Called(room, memberId)
}
// EmitSendRequest provides a mock function with given fields: room
func (_m *SocketService) EmitSendRequest(room string) {
_m.Called(room)
}
// NewSocketService creates a new instance of SocketService. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewSocketService(t testing.TB) *SocketService {
mock := &SocketService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/UserRepository.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// UserRepository is an autogenerated mock type for the UserRepository type
type UserRepository struct {
mock.Mock
}
// Create provides a mock function with given fields: user
func (_m *UserRepository) Create(user *model.User) (*model.User, error) {
ret := _m.Called(user)
var r0 *model.User
if rf, ok := ret.Get(0).(func(*model.User) *model.User); ok {
r0 = rf(user)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*model.User) error); ok {
r1 = rf(user)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByEmail provides a mock function with given fields: email
func (_m *UserRepository) FindByEmail(email string) (*model.User, error) {
ret := _m.Called(email)
var r0 *model.User
if rf, ok := ret.Get(0).(func(string) *model.User); ok {
r0 = rf(email)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(email)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByID provides a mock function with given fields: id
func (_m *UserRepository) FindByID(id string) (*model.User, error) {
ret := _m.Called(id)
var r0 *model.User
if rf, ok := ret.Get(0).(func(string) *model.User); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetFriendAndGuildIds provides a mock function with given fields: userId
func (_m *UserRepository) GetFriendAndGuildIds(userId string) (*[]string, error) {
ret := _m.Called(userId)
var r0 *[]string
if rf, ok := ret.Get(0).(func(string) *[]string); ok {
r0 = rf(userId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(userId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetRequestCount provides a mock function with given fields: userId
func (_m *UserRepository) GetRequestCount(userId string) (*int64, error) {
ret := _m.Called(userId)
var r0 *int64
if rf, ok := ret.Get(0).(func(string) *int64); ok {
r0 = rf(userId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*int64)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(userId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Update provides a mock function with given fields: user
func (_m *UserRepository) Update(user *model.User) error {
ret := _m.Called(user)
var r0 error
if rf, ok := ret.Get(0).(func(*model.User) error); ok {
r0 = rf(user)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewUserRepository creates a new instance of UserRepository. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewUserRepository(t testing.TB) *UserRepository {
mock := &UserRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/mocks/UserService.go
================================================
// Code generated by mockery v2.12.1. DO NOT EDIT.
package mocks
import (
context "context"
model "github.com/sentrionic/valkyrie/model"
mock "github.com/stretchr/testify/mock"
multipart "mime/multipart"
testing "testing"
)
// UserService is an autogenerated mock type for the UserService type
type UserService struct {
mock.Mock
}
// ChangeAvatar provides a mock function with given fields: header, directory
func (_m *UserService) ChangeAvatar(header *multipart.FileHeader, directory string) (string, error) {
ret := _m.Called(header, directory)
var r0 string
if rf, ok := ret.Get(0).(func(*multipart.FileHeader, string) string); ok {
r0 = rf(header, directory)
} else {
r0 = ret.Get(0).(string)
}
var r1 error
if rf, ok := ret.Get(1).(func(*multipart.FileHeader, string) error); ok {
r1 = rf(header, directory)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ChangePassword provides a mock function with given fields: currentPassword, newPassword, user
func (_m *UserService) ChangePassword(currentPassword string, newPassword string, user *model.User) error {
ret := _m.Called(currentPassword, newPassword, user)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, *model.User) error); ok {
r0 = rf(currentPassword, newPassword, user)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeleteImage provides a mock function with given fields: key
func (_m *UserService) DeleteImage(key string) error {
ret := _m.Called(key)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(key)
} else {
r0 = ret.Error(0)
}
return r0
}
// ForgotPassword provides a mock function with given fields: ctx, user
func (_m *UserService) ForgotPassword(ctx context.Context, user *model.User) error {
ret := _m.Called(ctx, user)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *model.User) error); ok {
r0 = rf(ctx, user)
} else {
r0 = ret.Error(0)
}
return r0
}
// Get provides a mock function with given fields: id
func (_m *UserService) Get(id string) (*model.User, error) {
ret := _m.Called(id)
var r0 *model.User
if rf, ok := ret.Get(0).(func(string) *model.User); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetByEmail provides a mock function with given fields: email
func (_m *UserService) GetByEmail(email string) (*model.User, error) {
ret := _m.Called(email)
var r0 *model.User
if rf, ok := ret.Get(0).(func(string) *model.User); ok {
r0 = rf(email)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(email)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetFriendAndGuildIds provides a mock function with given fields: userId
func (_m *UserService) GetFriendAndGuildIds(userId string) (*[]string, error) {
ret := _m.Called(userId)
var r0 *[]string
if rf, ok := ret.Get(0).(func(string) *[]string); ok {
r0 = rf(userId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*[]string)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(userId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetRequestCount provides a mock function with given fields: userId
func (_m *UserService) GetRequestCount(userId string) (*int64, error) {
ret := _m.Called(userId)
var r0 *int64
if rf, ok := ret.Get(0).(func(string) *int64); ok {
r0 = rf(userId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*int64)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(userId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// IsEmailAlreadyInUse provides a mock function with given fields: email
func (_m *UserService) IsEmailAlreadyInUse(email string) bool {
ret := _m.Called(email)
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(email)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// Login provides a mock function with given fields: email, password
func (_m *UserService) Login(email string, password string) (*model.User, error) {
ret := _m.Called(email, password)
var r0 *model.User
if rf, ok := ret.Get(0).(func(string, string) *model.User); ok {
r0 = rf(email, password)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(email, password)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Register provides a mock function with given fields: user
func (_m *UserService) Register(user *model.User) (*model.User, error) {
ret := _m.Called(user)
var r0 *model.User
if rf, ok := ret.Get(0).(func(*model.User) *model.User); ok {
r0 = rf(user)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*model.User) error); ok {
r1 = rf(user)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ResetPassword provides a mock function with given fields: ctx, password, token
func (_m *UserService) ResetPassword(ctx context.Context, password string, token string) (*model.User, error) {
ret := _m.Called(ctx, password, token)
var r0 *model.User
if rf, ok := ret.Get(0).(func(context.Context, string, string) *model.User); ok {
r0 = rf(ctx, password, token)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(ctx, password, token)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateAccount provides a mock function with given fields: user
func (_m *UserService) UpdateAccount(user *model.User) error {
ret := _m.Called(user)
var r0 error
if rf, ok := ret.Get(0).(func(*model.User) error); ok {
r0 = rf(user)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewUserService creates a new instance of UserService. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewUserService(t testing.TB) *UserService {
mock := &UserService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
================================================
FILE: server/model/app_constants.go
================================================
package model
// Application Constants
const (
MinimumChannels = 1
MaximumChannels = 50
MaximumGuilds = 100
CookieName = "vlk"
)
================================================
FILE: server/model/apperrors/apperrors.go
================================================
package apperrors
// Guild Errors
const (
NotAMember = "Not a member of the guild"
AlreadyMember = "Already a member of the guild"
GuildLimitReached = "The guild limit is 100"
MustBeOwner = "Must be the owner for that"
InvalidImageType = "imageFile must be 'image/jpeg' or 'image/png'"
MustBeMemberInvite = "Must be a member to fetch an invite"
IsPermanentError = "isPermanent is not a boolean"
InvalidateInvitesError = "Only the owner can invalidate invites"
InvalidInviteError = "Invalid Link or the server got deleted"
BannedFromServer = "You are banned from this server"
DeleteGuildError = "Only the owner can delete their server"
OwnerCantLeave = "The owner cannot leave their server"
BanYourselfError = "You cannot ban yourself"
KickYourselfError = "You cannot kick yourself"
UnbanYourselfError = "You cannot unban yourself"
OneChannelRequired = "A server needs at least one channel"
ChannelLimitError = "The channel limit is 50"
DMYourselfError = "You cannot dm yourself"
)
// Account Errors
const (
InvalidOldPassword = "Invalid old password"
InvalidCredentials = "Invalid email and password combination"
DuplicateEmail = "An account with that email already exists"
PasswordsDoNotMatch = "Passwords do not match"
InvalidResetToken = "Invalid reset token"
)
// Friend Errors
const (
AddYourselfError = "You cannot add yourself"
RemoveYourselfError = "You cannot remove yourself"
AcceptYourselfError = "You cannot accept yourself"
CancelYourselfError = "You cannot cancel yourself"
UnableAddError = "Unable to add user as friend. Try again later"
UnableRemoveError = "Unable to remove the user. Try again later"
UnableAcceptError = "Unable to accept the request. Try again later"
)
// Generic Errors
const (
InvalidSession = "Provided session is invalid"
ServerError = "Something went wrong. Try again later"
Unauthorized = "Not Authorized"
)
// Message Errors
const (
MessageOrFileRequired = "Either a message or a file is required"
EditMessageError = "Only the author can edit the message"
DeleteMessageError = "Only the author or owner can delete the message"
DeleteDMMessageError = "Only the author can delete the message"
)
================================================
FILE: server/model/apperrors/httperrors.go
================================================
package apperrors
import (
"errors"
"fmt"
"net/http"
)
// Type holds a type string and integer code for the error
type Type string
// "Set" of valid errorTypes
const (
Authorization Type = "AUTHORIZATION" // Authentication Failures -
BadRequest Type = "BADREQUEST" // Validation errors / BadInput
Conflict Type = "CONFLICT" // Already exists (eg, create account with existent email) - 409
Internal Type = "INTERNAL" // Server (500) and fallback errors
NotFound Type = "NOTFOUND" // For not finding resource
PayloadTooLarge Type = "PAYLOADTOOLARGE" // for uploading tons of JSON, or an image over the limit - 413
ServiceUnavailable Type = "SERVICE_UNAVAILABLE" // For long running handlers
UnsupportedMediaType Type = "UNSUPPORTEDMEDIATYPE" // for http 415
)
// Error holds a custom error for the application
// which is helpful in returning a consistent
// error type/message from API endpoints
type Error struct {
Type Type `json:"type"`
Message string `json:"message"`
}
// Error satisfies standard error interface
// we can return errors from this package as
// a regular old go _error_
func (e *Error) Error() string {
return e.Message
}
// Status is a mapping errors to status codes
// Of course, this is somewhat redundant since
// our errors already map http status codes
func (e *Error) Status() int {
switch e.Type {
case Authorization:
return http.StatusUnauthorized
case BadRequest:
return http.StatusBadRequest
case Conflict:
return http.StatusConflict
case Internal:
return http.StatusInternalServerError
case NotFound:
return http.StatusNotFound
case PayloadTooLarge:
return http.StatusRequestEntityTooLarge
case ServiceUnavailable:
return http.StatusServiceUnavailable
case UnsupportedMediaType:
return http.StatusUnsupportedMediaType
default:
return http.StatusInternalServerError
}
}
// Status checks the runtime type
// of the error and returns an http
// status code if the error is model.Error
func Status(err error) int {
var e *Error
if errors.As(err, &e) {
return e.Status()
}
return http.StatusInternalServerError
}
/*
* Error "Factories"
*/
// NewAuthorization to create a 401
func NewAuthorization(reason string) *Error {
return &Error{
Type: Authorization,
Message: reason,
}
}
// NewBadRequest to create 400 errors (validation, for example)
func NewBadRequest(reason string) *Error {
return &Error{
Type: BadRequest,
Message: fmt.Sprintf("Bad request. Reason: %v", reason),
}
}
// NewConflict to create an error for 409
func NewConflict(name string, value string) *Error {
return &Error{
Type: Conflict,
Message: fmt.Sprintf("resource: %v with value: %v already exists", name, value),
}
}
// NewInternal for 500 errors and unknown errors
func NewInternal() *Error {
return &Error{
Type: Internal,
Message: ServerError,
}
}
// NewNotFound to create an error for 404 with a generic error message
func NewNotFound(name string, value string) *Error {
return &Error{
Type: NotFound,
Message: fmt.Sprintf("resource: %v with value: %v not found", name, value),
}
}
// NewPayloadTooLarge to create an error for 413
func NewPayloadTooLarge(maxBodySize int64, contentLength int64) *Error {
return &Error{
Type: PayloadTooLarge,
Message: fmt.Sprintf("Max payload size of %v exceeded. Actual payload size: %v", maxBodySize, contentLength),
}
}
// NewServiceUnavailable to create an error for 503
func NewServiceUnavailable() *Error {
return &Error{
Type: ServiceUnavailable,
Message: "Service unavailable or timed out",
}
}
// NewUnsupportedMediaType to create an error for 415
func NewUnsupportedMediaType(reason string) *Error {
return &Error{
Type: UnsupportedMediaType,
Message: reason,
}
}
================================================
FILE: server/model/base_model.go
================================================
package model
import (
"time"
)
// BaseModel is similar to the gorm.Model and it includes the
// ID as a string, CreatedAt and UpdatedAt fields
type BaseModel struct {
ID string `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `gorm:"index" json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Success is the default response for successful operation
// Returns true without the JSON
type Success struct {
// Only returns true, not a json object
Success bool `json:"success"`
} //@name SuccessResponse
================================================
FILE: server/model/channel.go
================================================
package model
import "time"
// Channel represents a text channel in a guild
// or a text channel for DMs between users.
// GuildID should only be nil if it is a DM channel
// PCMembers should only be used if the channel is private.
type Channel struct {
BaseModel
GuildID *string `gorm:"index"`
Name string `gorm:"name"`
IsPublic bool `gorm:"index"`
IsDM bool `gorm:"is_dm"`
LastActivity time.Time `gorm:"autoCreateTime"`
PCMembers []User `gorm:"many2many:pcmembers;constraint:OnDelete:CASCADE;"`
Messages []Message `gorm:"constraint:OnDelete:CASCADE;"`
}
// ChannelResponse is the JSON response of the channel
type ChannelResponse struct {
Id string `json:"id"`
Name string `json:"name"`
IsPublic bool `json:"isPublic"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
HasNotification bool `json:"hasNotification"`
} //@name Channel
// SerializeChannel returns the channel API response.
func (c Channel) SerializeChannel() ChannelResponse {
return ChannelResponse{
Id: c.ID,
Name: c.Name,
IsPublic: c.IsPublic,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
HasNotification: false,
}
}
// ChannelService defines methods related to channel operations the handler layer expects
// any service it interacts with to implement
type ChannelService interface {
CreateChannel(channel *Channel) (*Channel, error)
GetChannels(userId string, guildId string) (*[]ChannelResponse, error)
Get(channelId string) (*Channel, error)
GetPrivateChannelMembers(channelId string) (*[]string, error)
GetDirectMessages(userId string) (*[]DirectMessage, error)
GetDirectMessageChannel(userId string, memberId string) (*string, error)
GetDMByUserAndChannel(userId string, channelId string) (string, error)
AddDMChannelMembers(memberIds []string, channelId string, userId string) error
SetDirectMessageStatus(dmId string, userId string, isOpen bool) error
DeleteChannel(channel *Channel) error
UpdateChannel(channel *Channel) error
CleanPCMembers(channelId string) error
AddPrivateChannelMembers(memberIds []string, channelId string) error
RemovePrivateChannelMembers(memberIds []string, channelId string) error
IsChannelMember(channel *Channel, userId string) error
OpenDMForAll(dmId string) error
}
// ChannelRepository defines methods related to channel db operations the service layer expects
// any repository it interacts with to implement
type ChannelRepository interface {
Create(channel *Channel) (*Channel, error)
GetGuildDefault(guildId string) (*Channel, error)
Get(userId string, guildId string) (*[]ChannelResponse, error)
GetDirectMessages(userId string) (*[]DirectMessage, error)
GetDirectMessageChannel(userId string, memberId string) (*string, error)
GetById(channelId string) (*Channel, error)
GetPrivateChannelMembers(channelId string) (*[]string, error)
AddDMChannelMembers(members []DMMember) error
SetDirectMessageStatus(dmId string, userId string, isOpen bool) error
DeleteChannel(channel *Channel) error
UpdateChannel(channel *Channel) error
CleanPCMembers(channelId string) error
AddPrivateChannelMembers(memberIds []string, channelId string) error
RemovePrivateChannelMembers(memberIds []string, channelId string) error
FindDMByUserAndChannelId(channelId, userId string) (string, error)
OpenDMForAll(dmId string) error
GetDMMemberIds(channelId string) (*[]string, error)
}
================================================
FILE: server/model/direct_message.go
================================================
package model
// DirectMessage is the json response of the channel ID
// and the other user of the DM.
type DirectMessage struct {
Id string `json:"id"`
User DMUser `json:"user"`
} //@name DirectMessage
// DMUser is the other member of the DM.
type DMUser struct {
Id string `json:"id"`
Username string `json:"username"`
Image string `json:"image"`
IsOnline bool `json:"isOnline"`
IsFriend bool `json:"isFriend"`
} //@name DMUser
================================================
FILE: server/model/dm_member.go
================================================
package model
import "time"
// DMMember represents a member of a DM channel.
// IsOpen indicates if the DM is open on the client.
type DMMember struct {
ID string `gorm:"primaryKey"`
UserID string `gorm:"primaryKey"`
ChannelId string `gorm:"primaryKey;"`
IsOpen bool
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time
}
================================================
FILE: server/model/field_error.go
================================================
package model
// ErrorsResponse contains a list of FieldError
type ErrorsResponse struct {
Errors []FieldError `json:"errors"`
} //@name ErrorsResponse
// FieldError is used to help extract validation errors
type FieldError struct {
// The property containing the error
Field string `json:"field"`
// The specific error message
Message string `json:"message"`
} //@name FieldError
// ErrorResponse holds a custom error for the application
type ErrorResponse struct {
Error HttpError `json:"error"`
} //@name ErrorResponse
// HttpError returns the Http error type and the specific message
type HttpError struct {
// The Http Response as a string
Type string `json:"type"`
// The specific error message
Message string `json:"message"`
} //@name HttpError
================================================
FILE: server/model/fixture/channel.go
================================================
package fixture
import (
"github.com/sentrionic/valkyrie/model"
"time"
)
// GetMockChannel returns a mock channel. If guildId is not empty it will set GuildID to that id.
func GetMockChannel(guildId string) *model.Channel {
var guild *string = nil
if guildId != "" {
guild = &guildId
}
return &model.Channel{
BaseModel: model.BaseModel{
ID: RandID(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
GuildID: guild,
Name: RandStr(8),
IsPublic: true,
LastActivity: time.Now(),
}
}
// GetMockDMChannel returns a mock channel that has IsDM set to true and does not belong to a guild.
func GetMockDMChannel() *model.Channel {
return &model.Channel{
BaseModel: model.BaseModel{
ID: RandID(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: RandID(),
IsDM: true,
LastActivity: time.Now(),
}
}
================================================
FILE: server/model/fixture/faker.go
================================================
package fixture
import (
"crypto/md5"
"encoding/hex"
"fmt"
"math/rand"
"strings"
)
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
var numberRunes = []rune("1234567890")
// RandStringRunes returns a random latin string of the given length
func RandStringRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
func randNumberRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = numberRunes[rand.Intn(len(numberRunes))]
}
return string(b)
}
func randStringLowerRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes)/2)]
}
return string(b)
}
// RandInt returns a random int within the given range
func RandInt(min, max int) int {
return rand.Intn(max-min+1) + min
}
// RandID returns a 15 character long numeric string
func RandID() string {
return randNumberRunes(15)
}
// Username returns a random string that's 4 to 15 characters long
func Username() string {
return RandStringRunes(RandInt(4, 15))
}
// Email returns a random email ending with @example.com
func Email() string {
email := fmt.Sprintf("%s@example.com", randStringLowerRunes(RandInt(5, 10)))
return strings.ToLower(email)
}
// RandStr returns a random string that has the given length
func RandStr(n int) string {
return RandStringRunes(n)
}
// generateAvatar returns an gravatar using the md5 hash of the email
func generateAvatar(email string) string {
hash := md5.Sum([]byte(email))
return fmt.Sprintf("https://gravatar.com/avatar/%s?d=identicon", hex.EncodeToString(hash[:]))
}
================================================
FILE: server/model/fixture/guild.go
================================================
package fixture
import (
"github.com/sentrionic/valkyrie/model"
"time"
)
// GetMockGuild returns a mock guild with the given uid as the owner.
func GetMockGuild(uid string) *model.Guild {
ownerId := RandID()
if uid != "" {
ownerId = uid
}
return &model.Guild{
BaseModel: model.BaseModel{
ID: RandID(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: RandStr(8),
OwnerId: ownerId,
}
}
================================================
FILE: server/model/fixture/message.go
================================================
package fixture
import (
"github.com/sentrionic/valkyrie/model"
"time"
)
// GetMockMessage returns a mock message with the given uid as the owner and the cid as the ChannelId
func GetMockMessage(uid, cid string) *model.Message {
text := RandStringRunes(100)
ownerId := RandID()
if uid != "" {
ownerId = uid
}
return &model.Message{
BaseModel: model.BaseModel{
ID: RandID(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Text: &text,
UserId: ownerId,
ChannelId: cid,
Attachment: nil,
}
}
// GetMockMessageResponse returns a mock message response with the given uid as the owner and the cid as the ChannelId
func GetMockMessageResponse(uid, cid string) *model.MessageResponse {
message := GetMockMessage(uid, cid)
user := GetMockUser()
return &model.MessageResponse{
Id: message.ID,
Text: message.Text,
CreatedAt: message.CreatedAt,
UpdatedAt: message.UpdatedAt,
Attachment: message.Attachment,
User: model.MemberResponse{
Id: user.ID,
Username: user.Username,
Image: user.Image,
IsOnline: user.IsOnline,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
Nickname: nil,
Color: nil,
IsFriend: false,
},
}
}
================================================
FILE: server/model/fixture/multipart.go
================================================
package fixture
import (
"bytes"
"fmt"
"image"
"image/png"
"io"
"mime"
"mime/multipart"
"net/textproto"
"os"
"path/filepath"
"runtime"
)
// MultipartImage used for instantiating a test fixture
// for creating multipart file uploads containing an image
type MultipartImage struct {
imagePath string
ImageFile *os.File
MultipartBody *bytes.Buffer
ContentType string
}
// NewMultipartImage creates an image file for testing
// and creates a Multipart Form with this image file
// for testing
func NewMultipartImage(fileName string, contentType string) *MultipartImage {
// create test file in same folder as this fixture
_, b, _, _ := runtime.Caller(0)
dir := filepath.Dir(b)
imagePath := filepath.Join(dir, fileName)
// f, _ := os.Open(imagePath)
f := createImage(imagePath)
defer func(f *os.File) {
_ = f.Close()
}(f)
// create a multipart write onto which we
// will write the image file
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// manually create form file as CreateFormFile will
// force file's content type to "application/octet-stream"
h := make(textproto.MIMEHeader)
h.Set(
"Content-Disposition",
fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "file", fileName),
)
h.Set("Content-Type", contentType)
part, _ := writer.CreatePart(h)
_, _ = io.Copy(part, f)
_ = writer.Close()
return &MultipartImage{
imagePath: imagePath,
ImageFile: f,
MultipartBody: body,
ContentType: writer.FormDataContentType(),
}
}
// GetFormFile extracts form file from multipart body
func (m *MultipartImage) GetFormFile() *multipart.FileHeader {
_, params, _ := mime.ParseMediaType(m.ContentType)
mr := multipart.NewReader(m.MultipartBody, params["boundary"])
form, _ := mr.ReadForm(1024)
files := form.File["file"]
return files[0]
}
// Close removes created file for test
func (m *MultipartImage) Close() {
_ = m.ImageFile.Close()
_ = os.Remove(m.imagePath)
}
// createImage used to create a quick example
// 1px x 1px image encoded as a PNG
func createImage(imagePath string) *os.File {
rect := image.Rect(0, 0, 1, 1)
img := image.NewRGBA(rect)
f, _ := os.Create(imagePath)
_ = png.Encode(f, img)
return f
}
================================================
FILE: server/model/fixture/user.go
================================================
package fixture
import (
"github.com/sentrionic/valkyrie/model"
"time"
)
// GetMockUser returns a mock user
func GetMockUser() *model.User {
email := Email()
return &model.User{
BaseModel: model.BaseModel{
ID: RandID(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Username: Username(),
Email: email,
Password: RandStr(8),
Image: generateAvatar(email),
}
}
================================================
FILE: server/model/friend.go
================================================
package model
// Friend represents the api response of a user's friend.
type Friend struct {
Id string `json:"id"`
Username string `json:"username"`
Image string `json:"image"`
IsOnline bool `json:"isOnline"`
} //@name Friend
// FriendService defines methods related to friend operations the handler layer expects
// any service it interacts with to implement
type FriendService interface {
GetFriends(id string) (*[]Friend, error)
GetRequests(id string) (*[]FriendRequest, error)
GetMemberById(id string) (*User, error)
DeleteRequest(memberId string, userId string) error
RemoveFriend(memberId string, userId string) error
SaveRequests(user *User) error
}
// FriendRepository defines methods related to friend db operations the service layer expects
// any repository it interacts with to implement
type FriendRepository interface {
FindByID(id string) (*User, error)
FriendsList(id string) (*[]Friend, error)
RequestList(id string) (*[]FriendRequest, error)
DeleteRequest(memberId string, userId string) error
RemoveFriend(memberId string, userId string) error
Save(user *User) error
}
================================================
FILE: server/model/friend_request.go
================================================
package model
// RequestType stands for the type of friend request
type RequestType int
// FriendRequest RequestType enum
const (
Outgoing RequestType = iota
Incoming
)
// FriendRequest contains all info to display request.
// Type stands for the type of the request.
// 1: Incoming,
// 0: Outgoing
type FriendRequest struct {
Id string `json:"id"`
Username string `json:"username"`
Image string `json:"image"`
// 1: Incoming, 0: Outgoing
Type RequestType `json:"type" enums:"0,1"`
} //@name FriendRequest
================================================
FILE: server/model/guild.go
================================================
package model
import (
"context"
"github.com/lib/pq"
"time"
)
// Guild represents the server many users can chat in.
type Guild struct {
BaseModel
Name string `gorm:"not null"`
OwnerId string `gorm:"not null"`
Icon *string
InviteLinks pq.StringArray `gorm:"type:text[]"`
Members []User `gorm:"many2many:members;constraint:OnDelete:CASCADE;"`
Channels []Channel `gorm:"constraint:OnDelete:CASCADE;"`
Bans []User `gorm:"many2many:bans;constraint:OnDelete:CASCADE;"`
VCMembers []User `gorm:"many2many:vc_members;constraint:OnDelete:CASCADE;"`
}
// GuildResponse contains all info to display a guild.
// The DefaultChannelId is the channel the user first gets directed to
// and is the oldest channel of the guild.
type GuildResponse struct {
Id string `json:"id"`
Name string `json:"name"`
OwnerId string `json:"ownerId"`
Icon *string `json:"icon"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
HasNotification bool `json:"hasNotification"`
DefaultChannelId string `json:"default_channel_id"`
} //@name GuildResponse
// SerializeGuild returns the guild API response.
// The DefaultChannelId represents the default channel the user gets send to.
func (g Guild) SerializeGuild(channelId string) GuildResponse {
return GuildResponse{
Id: g.ID,
Name: g.Name,
OwnerId: g.OwnerId,
Icon: g.Icon,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
HasNotification: false,
DefaultChannelId: channelId,
}
}
// GuildService defines methods related to guild operations the handler layer expects
// any service it interacts with to implement
type GuildService interface {
GetUser(uid string) (*User, error)
GetGuild(id string) (*Guild, error)
GetUserGuilds(uid string) (*[]GuildResponse, error)
GetGuildMembers(userId string, guildId string) (*[]MemberResponse, error)
GetVCMembers(guildId string) (*[]VCMemberResponse, error)
CreateGuild(guild *Guild) (*Guild, error)
GenerateInviteLink(ctx context.Context, guildId string, isPermanent bool) (string, error)
UpdateGuild(guild *Guild) error
GetGuildIdFromInvite(ctx context.Context, token string) (string, error)
GetDefaultChannel(guildId string) (*Channel, error)
InvalidateInvites(ctx context.Context, guild *Guild)
RemoveMember(userId string, guildId string) error
UnbanMember(userId string, guildId string) error
DeleteGuild(guildId string) error
GetBanList(guildId string) (*[]BanResponse, error)
GetMemberSettings(userId string, guildId string) (*MemberSettings, error)
UpdateMemberSettings(settings *MemberSettings, userId string, guildId string) error
FindUsersByIds(ids []string, guildId string) (*[]User, error)
UpdateMemberLastSeen(userId, guildId string) error
RemoveVCMember(userId, guildId string) error
UpdateVCMember(isMuted, isDeafened bool, userId, guildId string) error
GetVCMember(userId, guildId string) (*VCMember, error)
}
// GuildRepository defines methods related to guild db operations the service layer expects
// any repository it interacts with to implement
type GuildRepository interface {
FindUserByID(uid string) (*User, error)
FindByID(id string) (*Guild, error)
List(uid string) (*[]GuildResponse, error)
GuildMembers(userId string, guildId string) (*[]MemberResponse, error)
VCMembers(guildId string) (*[]VCMemberResponse, error)
Create(guild *Guild) (*Guild, error)
Save(guild *Guild) error
RemoveMember(userId string, guildId string) error
Delete(guildId string) error
UnbanMember(userId string, guildId string) error
GetBanList(guildId string) (*[]BanResponse, error)
GetMemberSettings(userId string, guildId string) (*MemberSettings, error)
UpdateMemberSettings(settings *MemberSettings, userId string, guildId string) error
FindUsersByIds(ids []string, guildId string) (*[]User, error)
GetMember(userId, guildId string) (*User, error)
UpdateMemberLastSeen(userId, guildId string) error
RemoveVCMember(userId, guildId string) error
GetMemberIds(guildId string) (*[]string, error)
UpdateVCMember(isMuted, isDeafened bool, userId, guildId string) error
GetVCMember(userId, guildId string) (*VCMember, error)
}
================================================
FILE: server/model/interfaces.go
================================================
package model
import (
"context"
"mime/multipart"
)
// FileRepository defines methods related to file upload the service layer expects
// any repository it interacts with to implement
type FileRepository interface {
UploadAvatar(header *multipart.FileHeader, directory string) (string, error)
UploadFile(header *multipart.FileHeader, directory, filename, mimetype string) (string, error)
DeleteImage(key string) error
}
// MailRepository defines methods related to mail operations the service layer expects
// any repository it interacts with to implement
type MailRepository interface {
SendResetMail(email string, html string) error
}
// RedisRepository defines methods related to the redis db the service layer expects
// any repository it interacts with to implement
type RedisRepository interface {
SetResetToken(ctx context.Context, id string) (string, error)
GetIdFromToken(ctx context.Context, token string) (string, error)
SaveInvite(ctx context.Context, guildId string, id string, isPermanent bool) error
GetInvite(ctx context.Context, token string) (string, error)
InvalidateInvites(ctx context.Context, guild *Guild)
}
================================================
FILE: server/model/invite.go
================================================
package model
// Invite represents an invite link for a guild
// IsPermanent indicates if the invite should not expire
type Invite struct {
GuildId string `json:"guild_id"`
IsPermanent bool `json:"is_permanent"`
}
================================================
FILE: server/model/member.go
================================================
package model
import "time"
// Member represents a user in a guild and is the join table between
// User and Guild.
type Member struct {
UserID string `gorm:"primaryKey;constraint:OnDelete:CASCADE;"`
GuildID string `gorm:"primaryKey;constraint:OnDelete:CASCADE;"`
Nickname *string `gorm:"nickname"`
Color *string `gorm:"color"`
LastSeen time.Time `gorm:"autoCreateTime"`
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time
}
type VCMember struct {
UserID string `gorm:"primaryKey;constraint:OnDelete:CASCADE;"`
GuildID string `gorm:"primaryKey;constraint:OnDelete:CASCADE;"`
IsMuted bool
IsDeafened bool
}
// MemberResponse is the API response of a member.
type MemberResponse struct {
Id string `json:"id"`
Username string `json:"username"`
Image string `json:"image"`
IsOnline bool `json:"isOnline"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Nickname *string `json:"nickname"`
Color *string `json:"color"`
IsFriend bool `json:"isFriend"`
} //@name Member
// BanResponse is the API response of a banned member.
type BanResponse struct {
Id string `json:"id"`
Username string `json:"username"`
Image string `json:"image"`
} //@name BanResponse
// MemberSettings is the API response of a member's guild settings.
type MemberSettings struct {
Nickname *string `json:"nickname"`
Color *string `json:"color"`
} //@name MemberSettings
// VCMemberResponse is the API response of a member that is currently in a VC.
type VCMemberResponse struct {
Id string `json:"id"`
Username string `json:"username"`
Image string `json:"image"`
IsMuted bool `json:"isMuted"`
IsDeafened bool `json:"IsDeafened"`
Nickname *string `json:"nickname"`
} //@name VCMember
================================================
FILE: server/model/message.go
================================================
package model
import (
"mime/multipart"
"time"
)
// Message represents a text message in a channel.
// It may contain an Attachment that is displayed instead of text.
type Message struct {
BaseModel
Text *string
UserId string `gorm:"index;constraint:OnDelete:CASCADE;"`
ChannelId string `gorm:"index;constraint:OnDelete:CASCADE;"`
Attachment *Attachment `gorm:"constraint:OnDelete:CASCADE;"`
}
// MessageResponse is the API response of a Message
type MessageResponse struct {
Id string `json:"id"`
Text *string `json:"text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Attachment *Attachment `json:"attachment"`
User MemberResponse `json:"user"`
} //@name Message
// Attachment represents a message attachment that displays
// a file instead of text.
type Attachment struct {
ID string `gorm:"primaryKey" json:"-"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
Url string `json:"url"`
FileType string `json:"filetype"`
Filename string `json:"filename"`
MessageId string `gorm:"index;constraint:OnDelete:CASCADE;" json:"-"`
} //@name Attachment
// MessageService defines methods related to message operations the handler layer expects
// any service it interacts with to implement
type MessageService interface {
GetMessages(userId string, channel *Channel, cursor string) (*[]MessageResponse, error)
CreateMessage(params *Message) (*Message, error)
UpdateMessage(message *Message) error
DeleteMessage(message *Message) error
UploadFile(header *multipart.FileHeader, channelId string) (*Attachment, error)
Get(messageId string) (*Message, error)
}
// MessageRepository defines methods related message db operations the service layer expects
// any repository it interacts with to implement
type MessageRepository interface {
GetMessages(userId string, channel *Channel, cursor string) (*[]MessageResponse, error)
CreateMessage(params *Message) (*Message, error)
UpdateMessage(message *Message) error
DeleteMessage(message *Message) error
GetById(messageId string) (*Message, error)
}
================================================
FILE: server/model/user.go
================================================
package model
import (
"context"
"mime/multipart"
)
// User represents the user of the website.
type User struct {
BaseModel
Username string `gorm:"not null" json:"username"`
Email string `gorm:"not null;uniqueIndex" json:"email"`
Password string `gorm:"not null" json:"-"`
Image string `json:"image"`
IsOnline bool `gorm:"index;default:true" json:"isOnline"`
Friends []User `gorm:"many2many:friends;" json:"-"`
Requests []User `gorm:"many2many:friend_requests;joinForeignKey:sender_id;joinReferences:receiver_id" json:"-"`
Guilds []Guild `gorm:"many2many:members;" json:"-"`
Message []Message `json:"-"`
} //@name User
// UserService defines methods related to account operations the handler layer expects
// any service it interacts with to implement
type UserService interface {
Get(id string) (*User, error)
GetByEmail(email string) (*User, error)
Register(user *User) (*User, error)
Login(email, password string) (*User, error)
UpdateAccount(user *User) error
IsEmailAlreadyInUse(email string) bool
ChangeAvatar(header *multipart.FileHeader, directory string) (string, error)
DeleteImage(key string) error
ChangePassword(currentPassword, newPassword string, user *User) error
ForgotPassword(ctx context.Context, user *User) error
ResetPassword(ctx context.Context, password string, token string) (*User, error)
GetFriendAndGuildIds(userId string) (*[]string, error)
GetRequestCount(userId string) (*int64, error)
}
// UserRepository defines methods related to account db operations the service layer expects
// any repository it interacts with to implement
type UserRepository interface {
FindByID(id string) (*User, error)
Create(user *User) (*User, error)
FindByEmail(email string) (*User, error)
Update(user *User) error
GetFriendAndGuildIds(userId string) (*[]string, error)
GetRequestCount(userId string) (*int64, error)
}
================================================
FILE: server/model/ws_message.go
================================================
package model
import (
"encoding/json"
"log"
)
// ReceivedMessage represents a received websocket message
type ReceivedMessage struct {
Action string `json:"action"`
Room string `json:"room"`
Message *any `json:"message"`
}
// WebsocketMessage represents an emitted message
type WebsocketMessage struct {
Action string `json:"action"`
Data any `json:"data"`
}
// Encode turns the message into a byte array
func (message *WebsocketMessage) Encode() []byte {
encoding, err := json.Marshal(message)
if err != nil {
log.Println(err)
}
return encoding
}
// SocketService defines methods related emitting websockets events the service layer expects
// any repository it interacts with to implement
type SocketService interface {
EmitNewMessage(room string, message *MessageResponse)
EmitEditMessage(room string, message *MessageResponse)
EmitDeleteMessage(room, messageId string)
EmitNewChannel(room string, channel *ChannelResponse)
EmitNewPrivateChannel(members []string, channel *ChannelResponse)
EmitEditChannel(room string, channel *ChannelResponse)
EmitDeleteChannel(channel *Channel)
EmitEditGuild(guild *Guild)
EmitDeleteGuild(guildId string, members []string)
EmitRemoveFromGuild(memberId, guildId string)
EmitAddMember(room string, member *User)
EmitRemoveMember(room, memberId string)
EmitNewDMNotification(channelId string, user *User)
EmitNewNotification(guildId, channelId string)
EmitSendRequest(room string)
EmitAddFriendRequest(room string, request *FriendRequest)
EmitAddFriend(user, member *User)
EmitRemoveFriend(userId, memberId string)
}
================================================
FILE: server/repository/channel_repository.go
================================================
package repository
import (
"database/sql"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"gorm.io/gorm"
"log"
"time"
)
// channelRepository is data/repository implementation
// of service layer ChannelRepository
type channelRepository struct {
DB *gorm.DB
}
// NewChannelRepository is a factory for initializing Channel Repositories
func NewChannelRepository(db *gorm.DB) model.ChannelRepository {
return &channelRepository{
DB: db,
}
}
// Create inserts a channel in the DB
func (r *channelRepository) Create(channel *model.Channel) (*model.Channel, error) {
if result := r.DB.Create(&channel); result.Error != nil {
log.Printf("Could not create a channel for guild: %v. Reason: %v\n", channel.GuildID, result.Error)
return nil, apperrors.NewInternal()
}
return channel, nil
}
// GetGuildDefault fetches the oldest channel for the given guildId from the DB
func (r *channelRepository) GetGuildDefault(guildId string) (*model.Channel, error) {
channel := model.Channel{}
result := r.DB.
Where("guild_id = ?", guildId).
Order("created_at ASC").
First(&channel)
return &channel, result.Error
}
// Get fetches all public channels for the given guildId
// and the private channels the given user is part in
func (r *channelRepository) Get(userId string, guildId string) (*[]model.ChannelResponse, error) {
var channels []model.ChannelResponse
result := r.DB.
Raw(`
SELECT DISTINCT ON (c.id, c."created_at") c.id, c.name,
c."is_public", c."created_at", c."updated_at",
(c."last_activity" > m."last_seen") AS "hasNotification"
FROM channels AS c
LEFT OUTER JOIN pcmembers as pc
ON c."id"::text = pc."channel_id"::text
LEFT OUTER JOIN members m on c."guild_id" = m."guild_id"
WHERE c."guild_id"::text = ?
AND (c."is_public" = true or pc."user_id"::text = ?)
ORDER BY c."created_at"
`, guildId, userId).
Scan(&channels)
return &channels, result.Error
}
// dmQuery represents the fetched fields for GetDirectMessages
type dmQuery struct {
ChannelId string
Id string
Username string
Image string
IsOnline bool
IsFriend bool
}
// GetDirectMessages returns all DMs for the given user
func (r *channelRepository) GetDirectMessages(userId string) (*[]model.DirectMessage, error) {
var results []dmQuery
err := r.DB.
Raw(`
SELECT dm."channel_id", u.username, u.image, u.id, u."is_online", u."created_at", u."updated_at"
FROM users u
JOIN dm_members dm ON dm."user_id" = u.id
WHERE u.id != @id
AND dm."channel_id" IN (
SELECT DISTINCT c.id
FROM channels as c
LEFT OUTER JOIN dm_members as dm
ON c."id" = dm."channel_id"
JOIN users u on dm."user_id" = u.id
WHERE c."is_public" = false
AND c.is_dm = true
AND dm."is_open" = true
AND dm."user_id" = @id
)
order by dm."updated_at" DESC
`, sql.Named("id", userId)).
Scan(&results)
var channels []model.DirectMessage
// Turn into DirectMessage response
for _, dm := range results {
channel := model.DirectMessage{
Id: dm.ChannelId,
User: model.DMUser{
Id: dm.Id,
Username: dm.Username,
Image: dm.Image,
IsOnline: dm.IsOnline,
IsFriend: dm.IsFriend,
},
}
channels = append(channels, channel)
}
return &channels, err.Error
}
// GetDirectMessageChannel returns the dm channel ID of the given members
// if it exists.
func (r *channelRepository) GetDirectMessageChannel(userId string, memberId string) (*string, error) {
var id string
result := r.DB.
Raw(`
SELECT c.id
FROM channels as c, dm_members dm
WHERE dm."channel_id" = c."id" AND c.is_dm = true AND c."is_public" = false
GROUP BY c."id"
HAVING array_agg(dm."user_id"::text) @> Array[?,?]
AND count(dm."user_id") = 2;
`, userId, memberId).
Scan(&id)
return &id, result.Error
}
// GetById returns the channel with its PCMembers for the given channel id
func (r *channelRepository) GetById(channelId string) (*model.Channel, error) {
var channel model.Channel
err := r.DB.Preload("PCMembers").Where("id = ?", channelId).First(&channel).Error
return &channel, err
}
// GetPrivateChannelMembers returns the ids of all users
// that are members of the given channel
func (r *channelRepository) GetPrivateChannelMembers(channelId string) (*[]string, error) {
var members []string
err := r.DB.
Raw("SELECT pc.user_id FROM pcmembers pc JOIN channels c ON pc.channel_id = c.id WHERE c.id = ?", channelId).
Scan(&members).Error
return &members, err
}
// AddDMChannelMembers inserts the given DM members in the DB
func (r *channelRepository) AddDMChannelMembers(members []model.DMMember) error {
if err := r.DB.CreateInBatches(&members, len(members)).Error; err != nil {
log.Printf("Could not add members to DM. Reason: %v\n", err)
return apperrors.NewInternal()
}
return nil
}
// SetDirectMessageStatus opens or closes the dm channel for the given
// userId
func (r *channelRepository) SetDirectMessageStatus(dmId string, userId string, isOpen bool) error {
err := r.DB.
Table("dm_members").
Where("channel_id = ? AND user_id = ?", dmId, userId).
Updates(map[string]any{
"is_open": isOpen,
"updated_at": time.Now(),
}).
Error
return err
}
// OpenDMForAll opens the given dm channel for all users in the channel
func (r *channelRepository) OpenDMForAll(dmId string) error {
err := r.DB.
Table("dm_members").
Where("channel_id = ? ", dmId).
Updates(map[string]any{
"is_open": true,
"updated_at": time.Now(),
}).
Error
return err
}
// DeleteChannel deletes the given channel from the DB
func (r *channelRepository) DeleteChannel(channel *model.Channel) error {
if result := r.DB.Delete(&channel); result.Error != nil {
log.Printf("Could not delete the channel with id: %v. Reason: %v\n", channel, result.Error)
return apperrors.NewInternal()
}
return nil
}
// UpdateChannel updates the given channel in the DB
func (r *channelRepository) UpdateChannel(channel *model.Channel) error {
if result := r.DB.Save(&channel); result.Error != nil {
log.Printf("Could not update the given channel: %v. Reason: %v\n", channel.ID, result.Error)
return apperrors.NewInternal()
}
return nil
}
// CleanPCMembers removes all private channel members from the given channel
func (r *channelRepository) CleanPCMembers(channelId string) error {
if result := r.DB.Exec("DELETE FROM pcmembers WHERE channel_id = ?", channelId); result.Error != nil {
log.Printf("Could not clean members from the channel with id: %v. Reason: %v\n", channelId, result.Error)
return apperrors.NewInternal()
}
return nil
}
// AddPrivateChannelMembers inserts the given member as PCMembers in the given channel
func (r *channelRepository) AddPrivateChannelMembers(memberIds []string, channelId string) error {
var err error = nil
for _, id := range memberIds {
err = r.DB.Exec("INSERT INTO pcmembers VALUES (?, ?)", channelId, id).Error
}
if err != nil {
log.Printf("Could not add members to private channel %s. Reason: %v\n", channelId, err)
return apperrors.NewInternal()
}
return nil
}
// RemovePrivateChannelMembers removes the given ids from the PCMembers of the given channel
func (r *channelRepository) RemovePrivateChannelMembers(memberIds []string, channelId string) error {
if err := r.DB.Exec("DELETE FROM pcmembers WHERE channel_id = ? AND user_id IN ?", channelId, memberIds).
Error; err != nil {
log.Printf("Could not remove members from private channel %s. Reason: %v\n", channelId, err)
return apperrors.NewInternal()
}
return nil
}
// FindDMByUserAndChannelId returns the id of dm channel for the given channelId and userId
func (r *channelRepository) FindDMByUserAndChannelId(channelId, userId string) (string, error) {
var id string
err := r.DB.
Raw("SELECT id FROM dm_members WHERE user_id = ? AND channel_id = ?", userId, channelId).
Scan(&id).Error
return id, err
}
// GetDMMemberIds returns the ids of all dm members for the given channel
func (r *channelRepository) GetDMMemberIds(channelId string) (*[]string, error) {
var members []string
err := r.DB.
Raw("SELECT u.id FROM users u JOIN dm_members dm on u.id = dm.user_id WHERE dm.channel_id = ?", channelId).
Scan(&members).Error
return &members, err
}
================================================
FILE: server/repository/file_repository.go
================================================
package repository
import (
"bytes"
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/disintegration/imaging"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/service"
"image"
"image/jpeg"
"log"
// Register accepted file type jpeg
_ "image/jpeg"
// Register accepted file type png
_ "image/png"
"mime/multipart"
)
// s3FileRepository includes the S3 session and the BucketName
type s3FileRepository struct {
S3Session *session.Session
BucketName string
}
// NewFileRepository is a factory for initializing the FileRepository
func NewFileRepository(session *session.Session, bucketName string) model.FileRepository {
return &s3FileRepository{
S3Session: session,
BucketName: bucketName,
}
}
// UploadAvatar uploads the given image to the initialized Bucket.
// The image gets resized before being uploaded.
// All images turn into jpeg images.
// It returns the url of the uploaded file.
func (s *s3FileRepository) UploadAvatar(header *multipart.FileHeader, directory string) (string, error) {
uploader := s3manager.NewUploader(s.S3Session)
id := service.GenerateId()
key := fmt.Sprintf("files/%s/%s.jpeg", directory, id)
file, err := header.Open()
if err != nil {
log.Printf("Failed to open header: %v\n", err.Error())
return "", apperrors.NewInternal()
}
src, _, err := image.Decode(file)
if err != nil {
log.Printf("Failed to decode image: %v\n", err.Error())
return "", apperrors.NewInternal()
}
img := imaging.Resize(src, 150, 0, imaging.Lanczos)
buf := new(bytes.Buffer)
err = jpeg.Encode(buf, img, &jpeg.Options{Quality: 75})
if err != nil {
log.Printf("Failed to encode image: %v\n", err.Error())
return "", apperrors.NewInternal()
}
up, err := uploader.Upload(&s3manager.UploadInput{
Body: buf,
Bucket: aws.String(s.BucketName),
ContentType: aws.String("image/jpeg"),
Key: aws.String(key),
})
if err != nil {
log.Printf("Failed to upload file: %v\n", err.Error())
return "", apperrors.NewInternal()
}
if err = file.Close(); err != nil {
log.Printf("Failed to close file: %v\n", err.Error())
return "", apperrors.NewInternal()
}
return up.Location, nil
}
// UploadFile uploads the given file to the initialized Bucket.
// It returns the url of the uploaded file.
func (s *s3FileRepository) UploadFile(header *multipart.FileHeader, directory, filename, mimetype string) (string, error) {
uploader := s3manager.NewUploader(s.S3Session)
key := fmt.Sprintf("files/%s/%s", directory, filename)
file, err := header.Open()
if err != nil {
log.Printf("Failed to open header: %v\n", err.Error())
return "", apperrors.NewInternal()
}
up, err := uploader.Upload(&s3manager.UploadInput{
Body: file,
Bucket: aws.String(s.BucketName),
ContentType: aws.String(mimetype),
Key: aws.String(key),
})
if err != nil {
log.Printf("Failed to upload file: %v\n", err.Error())
return "", apperrors.NewInternal()
}
if err = file.Close(); err != nil {
log.Printf("Failed to close file: %v\n", err.Error())
return "", apperrors.NewInternal()
}
return up.Location, nil
}
// DeleteImage deletes the file from the Bucket.
func (s *s3FileRepository) DeleteImage(key string) error {
srv := s3.New(s.S3Session)
_, err := srv.DeleteObject(&s3.DeleteObjectInput{
Bucket: aws.String(s.BucketName),
Key: aws.String(key),
})
if err != nil {
log.Printf("Failed to delete image: %v\n", err.Error())
return apperrors.NewInternal()
}
return nil
}
================================================
FILE: server/repository/friend_repository.go
================================================
package repository
import (
"database/sql"
"errors"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"gorm.io/gorm"
)
// friendRepository is data/repository implementation
// of service layer FriendRepository
type friendRepository struct {
DB *gorm.DB
}
// NewFriendRepository is a factory for initializing Friend Repositories
func NewFriendRepository(db *gorm.DB) model.FriendRepository {
return &friendRepository{
DB: db,
}
}
// FriendsList returns the friends for the given user ID from the DB
func (r *friendRepository) FriendsList(id string) (*[]model.Friend, error) {
var friends []model.Friend
result := r.DB.
Table("users").
Joins(`JOIN friends ON friends.user_id = "users".id`).
Where("friends.friend_id = ?", id).
Find(&friends)
return &friends, result.Error
}
// RequestList returns the friend requests for the given user ID from the DB
func (r *friendRepository) RequestList(id string) (*[]model.FriendRequest, error) {
var requests []model.FriendRequest
result := r.DB.
Raw(`
select u.id, u.username, u.image, 1 as "type" from users u
join friend_requests fr on u.id = fr."sender_id"
where fr."receiver_id" = @id
UNION
select u.id, u.username, u.image, 0 as "type" from users u
join friend_requests fr on u.id = fr."receiver_id"
where fr."sender_id" = @id
order by username;
`, sql.Named("id", id)).
Find(&requests)
return &requests, result.Error
}
// FindByID returns a User containing their friends and requests from the DB
func (r *friendRepository) FindByID(id string) (*model.User, error) {
user := &model.User{}
if err := r.DB.
Preload("Friends").
Preload("Requests").
Where("id = ?", id).
First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return user, apperrors.NewNotFound("uid", id)
}
return user, apperrors.NewInternal()
}
return user, nil
}
// DeleteRequest removes the given member and user from the friend requests
func (r *friendRepository) DeleteRequest(memberId string, userId string) error {
return r.DB.Exec(`
DELETE
FROM friend_requests
WHERE receiver_id = @memberId AND sender_id = @userId
OR receiver_id = @userId AND sender_id = @memberId
`, sql.Named("memberId", memberId), sql.Named("userId", userId)).Error
}
// RemoveFriend removes members from the friends table.
func (r *friendRepository) RemoveFriend(memberId string, userId string) error {
return r.DB.
Exec("DELETE FROM friends WHERE user_id = ? AND friend_id = ?", memberId, userId).
Exec("DELETE FROM friends WHERE user_id = ? AND friend_id = ?", userId, memberId).
Error
}
// Save inserts the given user in the DB
func (r *friendRepository) Save(user *model.User) error {
return r.DB.Save(&user).Error
}
================================================
FILE: server/repository/guild_repository.go
================================================
package repository
import (
"errors"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"log"
"time"
)
// guildRepository is data/repository implementation
// of service layer GuildRepository
type guildRepository struct {
DB *gorm.DB
}
// NewGuildRepository is a factory for initializing Guild Repositories
func NewGuildRepository(db *gorm.DB) model.GuildRepository {
return &guildRepository{
DB: db,
}
}
// List returns all of the given users guilds
func (r *guildRepository) List(uid string) (*[]model.GuildResponse, error) {
var guilds []model.GuildResponse
result := r.DB.Raw(`
SELECT distinct g."id",
g."name",
g."owner_id",
g."icon",
g."created_at",
g."updated_at",
((SELECT c."last_activity"
FROM channels c
JOIN guilds g ON g.id = c."guild_id"
WHERE g.id = member."guild_id"
order by c."last_activity" DESC
limit 1) > member."last_seen") AS "hasNotification",
(SELECT c.id AS "default_channel_id"
FROM channels c
JOIN guilds g ON g.id = c."guild_id"
WHERE g.id = member."guild_id"
ORDER BY c."created_at"
LIMIT 1)
FROM guilds g
JOIN members as member
on g."id"::text = member."guild_id"
WHERE member."user_id" = ?
ORDER BY g."created_at";
`, uid).Find(&guilds)
return &guilds, result.Error
}
// GuildMembers returns all members of the given guild and
// whether they are the given user IDs friend
func (r *guildRepository) GuildMembers(userId string, guildId string) (*[]model.MemberResponse, error) {
var members []model.MemberResponse
result := r.DB.Raw(`
SELECT u.id,
u.username,
u.image,
u."is_online",
u."created_at",
u."updated_at",
m.nickname,
m.color,
EXISTS(
SELECT 1
FROM users
LEFT JOIN friends f ON users.id = f."user_id"
WHERE f."friend_id" = u.id
AND f."user_id" = ?
) AS is_friend
FROM users AS u
JOIN members m ON u."id"::text = m."user_id"
WHERE m."guild_id" = ?
ORDER BY (CASE WHEN m.nickname notnull THEN m.nickname ELSE u.username END)
`, userId, guildId).Find(&members)
return &members, result.Error
}
// Create inserts the given guild in the DB
func (r *guildRepository) Create(guild *model.Guild) (*model.Guild, error) {
if result := r.DB.Create(&guild); result.Error != nil {
log.Printf("Could not create a guild for user: %v. Reason: %v\n", guild.OwnerId, result.Error)
return nil, apperrors.NewInternal()
}
return guild, nil
}
// FindUserByID returns a user containing all of their guilds
func (r *guildRepository) FindUserByID(uid string) (*model.User, error) {
user := &model.User{}
// we need to actually check errors as it could be something other than not found
if err := r.DB.
Preload("Guilds").
Where("id = ?", uid).
First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return user, apperrors.NewNotFound("uid", uid)
}
return user, apperrors.NewInternal()
}
return user, nil
}
// FindByID returns the guild for the given id containing all of their fields
func (r *guildRepository) FindByID(id string) (*model.Guild, error) {
guild := &model.Guild{}
if err := r.DB.
Preload(clause.Associations).
Where("id = ?", id).
First(&guild).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return guild, apperrors.NewNotFound("id", id)
}
return guild, apperrors.NewInternal()
}
return guild, nil
}
// Save updates the given guild
func (r *guildRepository) Save(guild *model.Guild) error {
if result := r.DB.Save(&guild); result.Error != nil {
log.Printf("Could not update the guild with id: %v. Reason: %v\n", guild.ID, result.Error)
return apperrors.NewInternal()
}
return nil
}
// RemoveMember removes the given user from the given guild
func (r *guildRepository) RemoveMember(userId string, guildId string) error {
if result := r.DB.
Exec("DELETE FROM members WHERE user_id = ? AND guild_id = ?", userId, guildId); result.Error != nil {
log.Printf("Could not remove member with id: %s from the guild with id: %v. Reason: %v\n", userId, guildId, result.Error)
return apperrors.NewInternal()
}
return nil
}
// Delete removes the given guild and all its associations
func (r *guildRepository) Delete(guildId string) error {
if result := r.DB.
Exec("DELETE FROM members WHERE guild_id = ?", guildId).
Exec("DELETE FROM bans WHERE guild_id = ?", guildId).
Exec("DELETE FROM guilds WHERE id = ?", guildId); result.Error != nil {
log.Printf("Could not delete the guild with id: %v. Reason: %v\n", guildId, result.Error)
return apperrors.NewInternal()
}
return nil
}
// UnbanMember removes the given user from the bans of the given guild
func (r *guildRepository) UnbanMember(userId string, guildId string) error {
if result := r.DB.Exec("DELETE FROM bans WHERE guild_id = ? AND user_id = ?", guildId, userId); result.Error != nil {
log.Printf("Could not unban the user with id: %v from the guild with id: %v. Reason: %v\n", userId, guildId, result.Error)
return apperrors.NewInternal()
}
return nil
}
// GetBanList returns a list of all banned users from the given guild
func (r *guildRepository) GetBanList(guildId string) (*[]model.BanResponse, error) {
var bans []model.BanResponse
if result := r.DB.Raw(`
select u.id, u.username, u.image
from bans b
join users u on b."user_id" = u.id
where b."guild_id" = ?
`, guildId).Scan(&bans); result.Error != nil {
log.Printf("Could not get the ban list for the guild with id: %v. Reason: %v\n", guildId, result.Error)
return &bans, apperrors.NewInternal()
}
return &bans, nil
}
// GetMemberSettings returns the given members settings in the given guild
func (r *guildRepository) GetMemberSettings(userId string, guildId string) (*model.MemberSettings, error) {
settings := model.MemberSettings{}
err := r.DB.
Table("members").
Where("user_id = ? AND guild_id = ?", userId, guildId).
First(&settings)
return &settings, err.Error
}
// UpdateMemberSettings updates the settings of the given member in the given guild
func (r *guildRepository) UpdateMemberSettings(settings *model.MemberSettings, userId string, guildId string) error {
err := r.DB.
Table("members").
Where("user_id = ? AND guild_id = ?", userId, guildId).
Updates(map[string]any{
"color": settings.Color,
"nickname": settings.Nickname,
"updated_at": time.Now(),
}).
Error
return err
}
// FindUsersByIds returns the found users for the given user IDs and guild ID
func (r *guildRepository) FindUsersByIds(ids []string, guildId string) (*[]model.User, error) {
var users []model.User
result := r.DB.Raw(`
SELECT u.*
FROM users AS u
JOIN members m ON u."id"::text = m."user_id"
WHERE m."guild_id" = ?
AND m."user_id" IN ?
`, guildId, ids).Find(&users)
return &users, result.Error
}
// GetMember returns user for the given userId and guildId
func (r *guildRepository) GetMember(userId, guildId string) (*model.User, error) {
var user model.User
result := r.DB.Raw(`
SELECT u.*
FROM users AS u
JOIN members m ON u."id"::text = m."user_id"
WHERE m."guild_id" = ?
AND m."user_id" = ?
`, guildId, userId).Find(&user)
return &user, result.Error
}
// UpdateMemberLastSeen sets the LastSeen field of the given user to the current date
func (r *guildRepository) UpdateMemberLastSeen(userId, guildId string) error {
err := r.DB.
Table("members").
Where("user_id = ? AND guild_id = ?", userId, guildId).
Updates(map[string]any{
"last_seen": time.Now(),
}).
Error
return err
}
// GetMemberIds returns the ids of all members of the given guild
func (r *guildRepository) GetMemberIds(guildId string) (*[]string, error) {
var users []string
result := r.DB.Raw(`
SELECT u.id
FROM users AS u
JOIN members m ON u."id"::text = m."user_id"
WHERE m."guild_id" = ?
`, guildId).Find(&users)
return &users, result.Error
}
func (r *guildRepository) RemoveVCMember(userId, guildId string) error {
if result := r.DB.Exec("DELETE FROM vc_members WHERE guild_id = ? AND user_id = ?", guildId, userId); result.Error != nil {
log.Printf("Could not add the user with id: %v to the vc of the guild with id: %v. Reason: %v\n", userId, guildId, result.Error)
return apperrors.NewInternal()
}
return nil
}
func (r *guildRepository) VCMembers(guildId string) (*[]model.VCMemberResponse, error) {
var members []model.VCMemberResponse
result := r.DB.Raw(`
SELECT u.id,
u.username,
u.image,
vm.is_muted,
vm.is_deafened,
m.nickname
FROM users AS u
JOIN vc_members vm ON u."id"::text = vm."user_id"
JOIN members m on vm.user_id = m.user_id AND vm.guild_id = m.guild_id
WHERE vm."guild_id" = ?
ORDER BY (CASE WHEN m.nickname notnull THEN m.nickname ELSE u.username END)
`, guildId).Find(&members)
return &members, result.Error
}
func (r *guildRepository) UpdateVCMember(isMuted, isDeafened bool, userId, guildId string) error {
err := r.DB.
Table("vc_members").
Where("user_id = ? AND guild_id = ?", userId, guildId).
Updates(map[string]any{
"is_muted": isMuted,
"is_deafened": isDeafened,
}).
Error
return err
}
func (r *guildRepository) GetVCMember(userId, guildId string) (*model.VCMember, error) {
var user model.VCMember
result := r.DB.Raw(`
SELECT * from vc_members vm
WHERE vm."guild_id" = ?
AND vm."user_id" = ?
`, guildId, userId).Find(&user)
return &user, result.Error
}
================================================
FILE: server/repository/mail_repository.go
================================================
package repository
import (
"fmt"
"github.com/sentrionic/valkyrie/model"
"net/smtp"
)
// mailRepository contains the gmail username and password
// as well as the frontend origin.
type mailRepository struct {
username string
password string
origin string
}
// NewMailRepository is a factory for initializing Mail Repositories
func NewMailRepository(username string, password string, origin string) model.MailRepository {
return &mailRepository{
username: username,
password: password,
origin: origin,
}
}
// SendResetMail sends a password reset email with the given reset token
func (m *mailRepository) SendResetMail(email string, token string) error {
msg := "From: " + m.username + "\n" +
"To: " + email + "\n" +
"Subject: Reset Email\n\n" +
fmt.Sprintf("Reset Password ", m.origin, token)
err := smtp.SendMail("smtp.gmail.com:587",
smtp.PlainAuth("", m.username, m.password, "smtp.gmail.com"),
m.username, []string{email}, []byte(msg))
return err
}
================================================
FILE: server/repository/message_repository.go
================================================
package repository
import (
"database/sql"
"errors"
"fmt"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"gorm.io/gorm"
"log"
"time"
)
// messageRepository is data/repository implementation
// of service layer MessageRepository
type messageRepository struct {
DB *gorm.DB
}
// NewMessageRepository is a factory for initializing Message Repositories
func NewMessageRepository(db *gorm.DB) model.MessageRepository {
return &messageRepository{
DB: db,
}
}
// messageQuery represents the fetched fields for GetMessages
type messageQuery struct {
Id string
Text *string
CreatedAt time.Time
UpdatedAt time.Time
FileType *string
Url *string
Filename *string
AttachmentId *string
UserId string
UserCreatedAt time.Time
UserUpdatedAt time.Time
Username string
Image string
IsOnline bool
Nickname *string
Color *string
IsFriend bool
}
// GetMessages returns the 35 most recent messages for the given channel.
// If a cursor is specified it returns the 35 messages after the cursor.
func (r *messageRepository) GetMessages(userId string, channel *model.Channel, cursor string) (*[]model.MessageResponse, error) {
var result []messageQuery
memberSelect := ""
memberJoin := ""
memberWhere := ""
// If the channel is not a DM channel, also fetch the message author's settings
if !channel.IsDM {
memberSelect = "member.nickname, member.color,"
memberJoin = "LEFT JOIN members member on messages.user_id = member.user_id"
memberWhere = fmt.Sprintf("AND member.guild_id = %s::text", *channel.GuildID)
}
crs := ""
if cursor != "" {
// Remove the timezone from the string since it's stored differently in the DB
date := cursor[:len(cursor)-6]
crs = fmt.Sprintf("AND messages.created_at < '%s'", date)
}
err := r.DB.
Raw(fmt.Sprintf(`
SELECT messages.id,
messages.text,
messages.created_at,
messages.updated_at,
a.file_type,
a.url,
a.filename,
a.id as "attachment_id",
users.id as "user_id",
users.created_at as "user_created_at",
users.updated_at as "user_updated_at",
users.username,
users.image,
users.is_online,
%s
EXISTS(
SELECT 1
FROM users
LEFT JOIN friends f ON users.id = f.user_id
WHERE f.friend_id = messages.user_id
AND f.user_id = @userId) as is_friend
FROM messages
LEFT JOIN "users"
ON users.id = messages.user_id
LEFT JOIN attachments a
ON a.message_id = messages.id
%s
WHERE messages.channel_id = @channelId
%s
%s
ORDER BY messages.created_at DESC
LIMIT 35
`, memberSelect, memberJoin, memberWhere, crs),
sql.Named("userId", userId),
sql.Named("channelId", channel.ID)).
Scan(&result).Error
var messages []model.MessageResponse
// Turn messageQuery results into MessageResponse
for _, m := range result {
var attachment *model.Attachment = nil
if m.AttachmentId != nil {
attachment = &model.Attachment{
Url: *m.Url,
FileType: *m.FileType,
Filename: *m.Filename,
}
}
message := model.MessageResponse{
Id: m.Id,
Text: m.Text,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
Attachment: attachment,
User: model.MemberResponse{
Id: m.UserId,
Username: m.Username,
Image: m.Image,
IsOnline: m.IsOnline,
CreatedAt: m.UserCreatedAt,
UpdatedAt: m.UserUpdatedAt,
Nickname: m.Nickname,
Color: m.Color,
IsFriend: m.IsFriend,
},
}
messages = append(messages, message)
}
return &messages, err
}
// CreateMessage inserts the message in the DB
func (r *messageRepository) CreateMessage(message *model.Message) (*model.Message, error) {
if result := r.DB.Create(&message); result.Error != nil {
log.Printf("Could not create a message for user: %v. Reason: %v\n", message.UserId, result.Error)
return nil, apperrors.NewInternal()
}
return message, nil
}
// UpdateMessage updates the message in the DB
func (r *messageRepository) UpdateMessage(message *model.Message) error {
if result := r.DB.Save(&message); result.Error != nil {
log.Printf("Could not update message with id: %v. Reason: %v\n", message.ID, result.Error)
return apperrors.NewInternal()
}
return nil
}
// DeleteMessage removes the message from the DB
func (r *messageRepository) DeleteMessage(message *model.Message) error {
if result := r.DB.Delete(message); result.Error != nil {
log.Printf("Could not delete message with id: %v. Reason: %v\n", message.ID, result.Error)
return apperrors.NewInternal()
}
return nil
}
// GetById fetches the message for the given id
func (r *messageRepository) GetById(messageId string) (*model.Message, error) {
message := &model.Message{}
if result := r.DB.Where("id = ?", messageId).First(message); result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return message, apperrors.NewNotFound("message", messageId)
}
return message, apperrors.NewInternal()
}
return message, nil
}
================================================
FILE: server/repository/redis_repository.go
================================================
package repository
import (
"context"
"encoding/json"
"fmt"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/redis/go-redis/v9"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"log"
"time"
)
type redisRepository struct {
rds *redis.Client
}
// NewRedisRepository is a factory for initializing Redis Repositories
func NewRedisRepository(rds *redis.Client) model.RedisRepository {
return &redisRepository{
rds: rds,
}
}
// Redis Prefixes
const (
InviteLinkPrefix = "inviteLink"
ForgotPasswordPrefix = "forgot-password"
)
// SetResetToken inserts a password reset token in the DB and returns the generated token
func (r *redisRepository) SetResetToken(ctx context.Context, id string) (string, error) {
uid, err := gonanoid.New()
if err != nil {
log.Printf("Failed to generate id: %v\n", err.Error())
return "", apperrors.NewInternal()
}
if err = r.rds.Set(ctx, fmt.Sprintf("%s:%s", ForgotPasswordPrefix, uid), id, 24*time.Hour).Err(); err != nil {
log.Printf("Failed to set link in redis: %v\n", err.Error())
return "", apperrors.NewInternal()
}
return uid, nil
}
// GetIdFromToken returns the user ID from the DB for the given token
func (r *redisRepository) GetIdFromToken(ctx context.Context, token string) (string, error) {
key := fmt.Sprintf("%s:%s", ForgotPasswordPrefix, token)
val, err := r.rds.Get(ctx, key).Result()
if err == redis.Nil {
return "", apperrors.NewBadRequest(apperrors.InvalidResetToken)
}
if err != nil {
log.Printf("Failed to get value from redis: %v\n", err)
return "", apperrors.NewInternal()
}
r.rds.Del(ctx, key)
return val, nil
}
// SaveInvite inserts an invite for the given guild in the DB.
// If isPermanent is true, the invite won't expire
func (r *redisRepository) SaveInvite(ctx context.Context, guildId string, id string, isPermanent bool) error {
invite := model.Invite{GuildId: guildId, IsPermanent: isPermanent}
value, err := json.Marshal(invite)
if err != nil {
log.Printf("Error marshalling: %v\n", err.Error())
return apperrors.NewInternal()
}
expiration := 24 * time.Hour
if isPermanent {
expiration = 0
}
if result := r.rds.Set(ctx, fmt.Sprintf("%s:%s", InviteLinkPrefix, id), value, expiration); result.Err() != nil {
log.Printf("Failed to set invite link in redis: %v\n", err.Error())
return apperrors.NewInternal()
}
return nil
}
// GetInvite returns the stored guild Id for the given token.
func (r *redisRepository) GetInvite(ctx context.Context, token string) (string, error) {
key := fmt.Sprintf("%s:%s", InviteLinkPrefix, token)
val, err := r.rds.Get(ctx, key).Result()
if err != nil {
log.Printf("Failed to get invite link from redis: %v\n", err.Error())
return "", apperrors.NewInternal()
}
var invite model.Invite
err = json.Unmarshal([]byte(val), &invite)
if err != nil {
log.Printf("Error unmarshalling: %v\n", err.Error())
return "", apperrors.NewInternal()
}
if !invite.IsPermanent {
r.rds.Del(ctx, key)
}
return invite.GuildId, nil
}
// InvalidateInvites deletes all permanent invites in the DB for the given guild
func (r *redisRepository) InvalidateInvites(ctx context.Context, guild *model.Guild) {
for _, v := range guild.InviteLinks {
key := fmt.Sprintf("%s:%s", InviteLinkPrefix, v)
r.rds.Del(ctx, key)
}
}
================================================
FILE: server/repository/user_repository.go
================================================
package repository
import (
"database/sql"
"errors"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"gorm.io/gorm"
"log"
"regexp"
)
// userRepository is data/repository implementation
// of service layer UserRepository
type userRepository struct {
DB *gorm.DB
}
// NewUserRepository is a factory for initializing User Repositories
func NewUserRepository(db *gorm.DB) model.UserRepository {
return &userRepository{
DB: db,
}
}
// FindByID returns a user for the given ID
func (r *userRepository) FindByID(id string) (*model.User, error) {
user := &model.User{}
// we need to actually check errors as it could be something other than not found
if err := r.DB.Where("id = ?", id).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return user, apperrors.NewNotFound("uid", id)
}
return user, apperrors.NewInternal()
}
return user, nil
}
// Create inserts the user in the DB
func (r *userRepository) Create(user *model.User) (*model.User, error) {
if result := r.DB.Create(&user); result.Error != nil {
// check unique constraint
if isDuplicateKeyError(result.Error) {
return nil, apperrors.NewBadRequest(apperrors.DuplicateEmail)
}
log.Printf("Could not create a user with email: %v. Reason: %v\n", user.Email, result.Error)
return nil, apperrors.NewInternal()
}
return user, nil
}
// FindByEmail retrieves user row by email address
func (r *userRepository) FindByEmail(email string) (*model.User, error) {
user := &model.User{}
// we need to actually check errors as it could be something other than not found
if err := r.DB.Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return user, apperrors.NewNotFound("email", email)
}
return user, apperrors.NewInternal()
}
return user, nil
}
// Update updates the user in the DB
func (r *userRepository) Update(user *model.User) error {
return r.DB.Save(&user).Error
}
// GetFriendAndGuildIds returns the id of the users friends and the guilds they are part of
func (r *userRepository) GetFriendAndGuildIds(userId string) (*[]string, error) {
var ids []string
result := r.DB.Raw(`
SELECT g.id
FROM guilds g
JOIN members m on m.guild_id = g."id"
where m.user_id = @userId
UNION
SELECT "User__friends"."id"
FROM "users" "User" LEFT JOIN "friends" "User_User__friends" ON "User_User__friends"."user_id"="User"."id" LEFT
JOIN "users" "User__friends" ON "User__friends"."id"="User_User__friends"."friend_id"
WHERE ( "User"."id" = @userId )
`, sql.Named("userId", userId)).Find(&ids)
return &ids, result.Error
}
// GetRequestCount returns the amount of incoming friend requests the current user currently has
func (r *userRepository) GetRequestCount(userId string) (*int64, error) {
var count int64
err := r.DB.
Table("users").
Joins("JOIN friend_requests fr ON users.id = fr.sender_id").
Where("fr.receiver_id = ?", userId).
Count(&count).
Error
return &count, err
}
// isDuplicateKeyError checks if the provided error is a PostgreSQL duplicate key error
func isDuplicateKeyError(err error) bool {
duplicate := regexp.MustCompile(`\(SQLSTATE 23505\)$`)
return duplicate.MatchString(err.Error())
}
================================================
FILE: server/service/channel_service.go
================================================
package service
import (
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
)
// channelService acts as a struct for injecting an implementation of ChannelRepository
// for use in service methods
type channelService struct {
ChannelRepository model.ChannelRepository
GuildRepository model.GuildRepository
}
// CSConfig will hold repositories that will eventually be injected into
// this service layer
type CSConfig struct {
ChannelRepository model.ChannelRepository
GuildRepository model.GuildRepository
}
// NewChannelService is a factory function for
// initializing a ChannelService with its repository layer dependencies
func NewChannelService(c *CSConfig) model.ChannelService {
return &channelService{
ChannelRepository: c.ChannelRepository,
GuildRepository: c.GuildRepository,
}
}
func (c *channelService) CreateChannel(channel *model.Channel) (*model.Channel, error) {
channel.ID = GenerateId()
return c.ChannelRepository.Create(channel)
}
func (c *channelService) GetChannels(userId string, guildId string) (*[]model.ChannelResponse, error) {
return c.ChannelRepository.Get(userId, guildId)
}
func (c *channelService) Get(channelId string) (*model.Channel, error) {
return c.ChannelRepository.GetById(channelId)
}
func (c *channelService) GetPrivateChannelMembers(channelId string) (*[]string, error) {
return c.ChannelRepository.GetPrivateChannelMembers(channelId)
}
func (c *channelService) GetDirectMessages(userId string) (*[]model.DirectMessage, error) {
return c.ChannelRepository.GetDirectMessages(userId)
}
func (c *channelService) GetDirectMessageChannel(userId string, memberId string) (*string, error) {
return c.ChannelRepository.GetDirectMessageChannel(userId, memberId)
}
func (c *channelService) AddDMChannelMembers(memberIds []string, channelId string, userId string) error {
var members []model.DMMember
for _, mId := range memberIds {
member := model.DMMember{
ID: GenerateId(),
UserID: mId,
ChannelId: channelId,
IsOpen: userId == mId,
}
members = append(members, member)
}
return c.ChannelRepository.AddDMChannelMembers(members)
}
func (c *channelService) SetDirectMessageStatus(dmId string, userId string, isOpen bool) error {
return c.ChannelRepository.SetDirectMessageStatus(dmId, userId, isOpen)
}
func (c *channelService) DeleteChannel(channel *model.Channel) error {
return c.ChannelRepository.DeleteChannel(channel)
}
func (c *channelService) UpdateChannel(channel *model.Channel) error {
return c.ChannelRepository.UpdateChannel(channel)
}
func (c *channelService) CleanPCMembers(channelId string) error {
return c.ChannelRepository.CleanPCMembers(channelId)
}
func (c *channelService) AddPrivateChannelMembers(memberIds []string, channelId string) error {
return c.ChannelRepository.AddPrivateChannelMembers(memberIds, channelId)
}
func (c *channelService) RemovePrivateChannelMembers(memberIds []string, channelId string) error {
return c.ChannelRepository.RemovePrivateChannelMembers(memberIds, channelId)
}
func (c *channelService) OpenDMForAll(dmId string) error {
return c.ChannelRepository.OpenDMForAll(dmId)
}
func (c *channelService) GetDMByUserAndChannel(userId string, channelId string) (string, error) {
return c.ChannelRepository.FindDMByUserAndChannelId(channelId, userId)
}
// IsChannelMember checks if the user has access to the given channel.
// Returns an error if they do not, otherwise nil
func (c *channelService) IsChannelMember(channel *model.Channel, userId string) error {
// Check if user has access to the channel if it's private
if !channel.IsPublic {
// Channel is DM -> Check if one of the members
if channel.IsDM {
id, err := c.ChannelRepository.FindDMByUserAndChannelId(channel.ID, userId)
if err != nil || id == "" {
return apperrors.NewAuthorization(apperrors.Unauthorized)
}
return nil
}
// Channel is private
for _, member := range channel.PCMembers {
if member.ID == userId {
return nil
}
}
return apperrors.NewAuthorization(apperrors.Unauthorized)
}
// Check if user has access to the channel
member, err := c.GuildRepository.GetMember(userId, *channel.GuildID)
if err != nil || member.ID == "" {
return apperrors.NewAuthorization(apperrors.Unauthorized)
}
return nil
}
================================================
FILE: server/service/channel_service_test.go
================================================
package service
import (
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"testing"
)
func TestChannelService_CreateChannel(t *testing.T) {
t.Run("Success", func(t *testing.T) {
uid := GenerateId()
mockChannel := fixture.GetMockChannel("")
params := &model.Channel{
GuildID: mockChannel.GuildID,
Name: mockChannel.Name,
IsPublic: true,
}
mockChannelRepository := new(mocks.ChannelRepository)
cs := NewChannelService(&CSConfig{
ChannelRepository: mockChannelRepository,
})
mockChannelRepository.
On("Create", params).
Run(func(args mock.Arguments) {
mockChannel.ID = uid
}).Return(mockChannel, nil)
channel, err := cs.CreateChannel(params)
assert.NoError(t, err)
assert.Equal(t, uid, mockChannel.ID)
assert.Equal(t, channel, mockChannel)
mockChannelRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
mockChannel := fixture.GetMockChannel("")
params := &model.Channel{
GuildID: mockChannel.GuildID,
Name: mockChannel.Name,
IsPublic: true,
}
mockChannelRepository := new(mocks.ChannelRepository)
cs := NewChannelService(&CSConfig{
ChannelRepository: mockChannelRepository,
})
mockErr := apperrors.NewInternal()
mockChannelRepository.
On("Create", params).
Return(nil, mockErr)
channel, err := cs.CreateChannel(params)
// assert error is error we response with in mock
assert.EqualError(t, err, mockErr.Error())
assert.Nil(t, channel)
mockChannelRepository.AssertExpectations(t)
})
}
func TestChannelService_AddDMChannelMembers(t *testing.T) {
userId := fixture.RandID()
ids := []string{userId, fixture.RandID()}
channelId := fixture.RandID()
t.Run("Success", func(t *testing.T) {
mockChannelRepository := new(mocks.ChannelRepository)
cs := NewChannelService(&CSConfig{
ChannelRepository: mockChannelRepository,
})
mockChannelRepository.
On("AddDMChannelMembers", mock.AnythingOfType("[]model.DMMember")).
// Has to be added so the different IDs get accepted
Return(nil)
err := cs.AddDMChannelMembers(ids, channelId, userId)
assert.NoError(t, err)
mockChannelRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
mockChannelRepository := new(mocks.ChannelRepository)
cs := NewChannelService(&CSConfig{
ChannelRepository: mockChannelRepository,
})
mockError := apperrors.NewInternal()
mockChannelRepository.
On("AddDMChannelMembers", mock.AnythingOfType("[]model.DMMember")).
// Has to be added so the different IDs get accepted
Return(mockError)
err := cs.AddDMChannelMembers(ids, channelId, userId)
assert.Error(t, err)
mockChannelRepository.AssertExpectations(t)
})
}
func TestChannelService_IsChannelMember(t *testing.T) {
mockUser := fixture.GetMockUser()
t.Run("User is member of the DM", func(t *testing.T) {
mockChannel := fixture.GetMockDMChannel()
mockChannelRepository := new(mocks.ChannelRepository)
cs := NewChannelService(&CSConfig{
ChannelRepository: mockChannelRepository,
})
mockChannelRepository.On("FindDMByUserAndChannelId", mockChannel.ID, mockUser.ID).Return(fixture.RandID(), nil)
err := cs.IsChannelMember(mockChannel, mockUser.ID)
assert.NoError(t, err)
})
t.Run("User is not member of the DM", func(t *testing.T) {
mockChannel := fixture.GetMockDMChannel()
mockChannelRepository := new(mocks.ChannelRepository)
cs := NewChannelService(&CSConfig{
ChannelRepository: mockChannelRepository,
})
mockError := apperrors.NewAuthorization(apperrors.Unauthorized)
mockChannelRepository.On("FindDMByUserAndChannelId", mockChannel.ID, mockUser.ID).Return("", mockError)
err := cs.IsChannelMember(mockChannel, mockUser.ID)
assert.Error(t, err)
assert.Equal(t, err, mockError)
})
t.Run("User is member of the private channel", func(t *testing.T) {
mockChannel := fixture.GetMockChannel("")
mockChannel.IsPublic = false
mockChannel.PCMembers = append(mockChannel.PCMembers, *mockUser)
mockChannelRepository := new(mocks.ChannelRepository)
cs := NewChannelService(&CSConfig{
ChannelRepository: mockChannelRepository,
})
err := cs.IsChannelMember(mockChannel, mockUser.ID)
assert.NoError(t, err)
})
t.Run("User is not member of the private channel", func(t *testing.T) {
mockChannel := fixture.GetMockChannel("")
mockChannel.IsPublic = false
mockChannelRepository := new(mocks.ChannelRepository)
cs := NewChannelService(&CSConfig{
ChannelRepository: mockChannelRepository,
})
err := cs.IsChannelMember(mockChannel, mockUser.ID)
assert.Error(t, err)
assert.Equal(t, err, apperrors.NewAuthorization(apperrors.Unauthorized))
})
t.Run("User is a guild member", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockGuild.Members = append(mockGuild.Members, *mockUser)
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildRepository := new(mocks.GuildRepository)
cs := NewChannelService(&CSConfig{
GuildRepository: mockGuildRepository,
})
mockGuildRepository.On("GetMember", mockUser.ID, *mockChannel.GuildID).Return(mockUser, nil)
err := cs.IsChannelMember(mockChannel, mockUser.ID)
assert.NoError(t, err)
})
t.Run("User is not a member of the guild", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
mockChannel := fixture.GetMockChannel(mockGuild.ID)
mockGuildRepository := new(mocks.GuildRepository)
cs := NewChannelService(&CSConfig{
GuildRepository: mockGuildRepository,
})
mockError := apperrors.NewAuthorization(apperrors.Unauthorized)
mockGuildRepository.On("GetMember", mockUser.ID, *mockChannel.GuildID).Return(nil, mockError)
err := cs.IsChannelMember(mockChannel, mockUser.ID)
assert.Error(t, err)
assert.Equal(t, err, mockError)
})
}
================================================
FILE: server/service/friend_service.go
================================================
package service
import (
"github.com/sentrionic/valkyrie/model"
)
// friendService acts as a struct for injecting an implementation of UserRepository
// and FriendRepository for use in service methods
type friendService struct {
UserRepository model.UserRepository
FriendRepository model.FriendRepository
}
// FSConfig will hold repositories that will eventually be injected into
// this service layer
type FSConfig struct {
UserRepository model.UserRepository
FriendRepository model.FriendRepository
}
// NewFriendService is a factory function for
// initializing a FriendService with its repository layer dependencies
func NewFriendService(c *FSConfig) model.FriendService {
return &friendService{
UserRepository: c.UserRepository,
FriendRepository: c.FriendRepository,
}
}
func (f *friendService) GetFriends(id string) (*[]model.Friend, error) {
return f.FriendRepository.FriendsList(id)
}
func (f *friendService) GetRequests(id string) (*[]model.FriendRequest, error) {
return f.FriendRepository.RequestList(id)
}
func (f *friendService) GetMemberById(id string) (*model.User, error) {
return f.FriendRepository.FindByID(id)
}
func (f *friendService) DeleteRequest(memberId string, userId string) error {
return f.FriendRepository.DeleteRequest(memberId, userId)
}
func (f *friendService) RemoveFriend(memberId string, userId string) error {
return f.FriendRepository.RemoveFriend(memberId, userId)
}
func (f *friendService) SaveRequests(user *model.User) error {
return f.FriendRepository.Save(user)
}
================================================
FILE: server/service/guild_service.go
================================================
package service
import (
"context"
gonanoid "github.com/matoous/go-nanoid"
"github.com/sentrionic/valkyrie/model"
)
// GuildService acts as a struct for injecting an implementation of GuildRepository
// for use in service methods
type guildService struct {
UserRepository model.UserRepository
FileRepository model.FileRepository
RedisRepository model.RedisRepository
GuildRepository model.GuildRepository
ChannelRepository model.ChannelRepository
}
// GSConfig will hold repositories that will eventually be injected into
// this service layer
type GSConfig struct {
UserRepository model.UserRepository
FileRepository model.FileRepository
RedisRepository model.RedisRepository
GuildRepository model.GuildRepository
ChannelRepository model.ChannelRepository
}
// NewGuildService is a factory function for
// initializing a GuildService with its repository layer dependencies
func NewGuildService(c *GSConfig) model.GuildService {
return &guildService{
UserRepository: c.UserRepository,
FileRepository: c.FileRepository,
RedisRepository: c.RedisRepository,
GuildRepository: c.GuildRepository,
ChannelRepository: c.ChannelRepository,
}
}
func (g *guildService) GetUserGuilds(uid string) (*[]model.GuildResponse, error) {
return g.GuildRepository.List(uid)
}
func (g *guildService) GetGuildMembers(userId string, guildId string) (*[]model.MemberResponse, error) {
return g.GuildRepository.GuildMembers(userId, guildId)
}
func (g *guildService) CreateGuild(guild *model.Guild) (*model.Guild, error) {
guild.ID = GenerateId()
return g.GuildRepository.Create(guild)
}
func (g *guildService) GetUser(uid string) (*model.User, error) {
return g.GuildRepository.FindUserByID(uid)
}
func (g *guildService) GetGuild(id string) (*model.Guild, error) {
return g.GuildRepository.FindByID(id)
}
func (g *guildService) GenerateInviteLink(ctx context.Context, guildId string, isPermanent bool) (string, error) {
id, err := gonanoid.Nanoid(8)
if err != nil {
return "", err
}
if err = g.RedisRepository.SaveInvite(ctx, guildId, id, isPermanent); err != nil {
return "", err
}
return id, nil
}
func (g *guildService) UpdateGuild(guild *model.Guild) error {
return g.GuildRepository.Save(guild)
}
func (g *guildService) GetGuildIdFromInvite(ctx context.Context, token string) (string, error) {
return g.RedisRepository.GetInvite(ctx, token)
}
func (g *guildService) GetDefaultChannel(guildId string) (*model.Channel, error) {
return g.ChannelRepository.GetGuildDefault(guildId)
}
func (g *guildService) InvalidateInvites(ctx context.Context, guild *model.Guild) {
g.RedisRepository.InvalidateInvites(ctx, guild)
}
func (g *guildService) RemoveMember(userId string, guildId string) error {
return g.GuildRepository.RemoveMember(userId, guildId)
}
func (g *guildService) DeleteGuild(guildId string) error {
return g.GuildRepository.Delete(guildId)
}
func (g *guildService) UnbanMember(userId string, guildId string) error {
return g.GuildRepository.UnbanMember(userId, guildId)
}
func (g *guildService) GetBanList(guildId string) (*[]model.BanResponse, error) {
return g.GuildRepository.GetBanList(guildId)
}
func (g *guildService) GetMemberSettings(userId string, guildId string) (*model.MemberSettings, error) {
return g.GuildRepository.GetMemberSettings(userId, guildId)
}
func (g *guildService) UpdateMemberSettings(settings *model.MemberSettings, userId string, guildId string) error {
return g.GuildRepository.UpdateMemberSettings(settings, userId, guildId)
}
func (g *guildService) FindUsersByIds(ids []string, guildId string) (*[]model.User, error) {
return g.GuildRepository.FindUsersByIds(ids, guildId)
}
func (g *guildService) UpdateMemberLastSeen(userId, guildId string) error {
return g.GuildRepository.UpdateMemberLastSeen(userId, guildId)
}
func (g *guildService) RemoveVCMember(userId, guildId string) error {
return g.GuildRepository.RemoveVCMember(userId, guildId)
}
func (g *guildService) GetVCMembers(guildId string) (*[]model.VCMemberResponse, error) {
return g.GuildRepository.VCMembers(guildId)
}
func (g *guildService) UpdateVCMember(isMuted, isDeafened bool, userId, guildId string) error {
return g.GuildRepository.UpdateVCMember(isMuted, isDeafened, userId, guildId)
}
func (g *guildService) GetVCMember(userId, guildId string) (*model.VCMember, error) {
return g.GuildRepository.GetVCMember(userId, guildId)
}
================================================
FILE: server/service/guild_service_test.go
================================================
package service
import (
"context"
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"testing"
)
func TestGuildService_CreateGuild(t *testing.T) {
t.Run("Success", func(t *testing.T) {
uid := GenerateId()
mockGuild := fixture.GetMockGuild("")
params := &model.Guild{
Name: mockGuild.Name,
}
mockGuildRepository := new(mocks.GuildRepository)
gs := NewGuildService(&GSConfig{
GuildRepository: mockGuildRepository,
})
mockGuildRepository.
On("Create", params).
Run(func(args mock.Arguments) {
mockGuild.ID = uid
}).Return(mockGuild, nil)
guild, err := gs.CreateGuild(params)
assert.NoError(t, err)
assert.Equal(t, uid, mockGuild.ID)
assert.Equal(t, guild, mockGuild)
mockGuildRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
mockGuild := fixture.GetMockGuild("")
params := &model.Guild{
Name: mockGuild.Name,
}
mockGuildRepository := new(mocks.GuildRepository)
gs := NewGuildService(&GSConfig{
GuildRepository: mockGuildRepository,
})
mockErr := apperrors.NewInternal()
mockGuildRepository.
On("Create", params).
Return(nil, mockErr)
guild, err := gs.CreateGuild(params)
// assert error is error we response with in mock
assert.EqualError(t, err, mockErr.Error())
assert.Nil(t, guild)
mockGuildRepository.AssertExpectations(t)
})
}
func TestGuildService_GenerateInviteLink(t *testing.T) {
guildId := fixture.RandID()
ctx := context.TODO()
t.Run("Success", func(t *testing.T) {
mockRedisRepository := new(mocks.RedisRepository)
gs := NewGuildService(&GSConfig{
RedisRepository: mockRedisRepository,
})
args := mock.Arguments{
ctx,
guildId,
mock.AnythingOfType("string"),
false,
}
mockRedisRepository.
On("SaveInvite", args...).
Run(func(args mock.Arguments) {}).
Return(nil)
link, err := gs.GenerateInviteLink(ctx, guildId, false)
assert.NoError(t, err)
assert.NotEqual(t, link, "")
mockRedisRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
mockRedisRepository := new(mocks.RedisRepository)
gs := NewGuildService(&GSConfig{
RedisRepository: mockRedisRepository,
})
args := mock.Arguments{
ctx,
guildId,
mock.AnythingOfType("string"),
false,
}
mockError := apperrors.NewInternal()
mockRedisRepository.
On("SaveInvite", args...).
Run(func(args mock.Arguments) {}).
Return(mockError)
link, err := gs.GenerateInviteLink(ctx, guildId, false)
assert.Error(t, err)
assert.Equal(t, link, "")
assert.Equal(t, err, mockError)
mockRedisRepository.AssertExpectations(t)
})
}
================================================
FILE: server/service/id_generator.go
================================================
package service
import (
"github.com/bwmarrin/snowflake"
"log"
)
var node *snowflake.Node
func init() {
const nodeID int64 = 1
var err error
node, err = snowflake.NewNode(nodeID)
if err != nil {
log.Fatalf("failed to init snowflake node: %v", err.Error())
}
}
// GenerateId generates a snowflake id
func GenerateId() string {
// Generate a snowflake ID.
id := node.Generate()
return id.String()
}
================================================
FILE: server/service/message_service.go
================================================
package service
import (
"fmt"
gonanoid "github.com/matoous/go-nanoid"
"github.com/sentrionic/valkyrie/model"
"log"
"mime/multipart"
"path"
"path/filepath"
"regexp"
"strings"
)
// messageService acts as a struct for injecting an implementation of MessageRepository
// for use in service methods
type messageService struct {
MessageRepository model.MessageRepository
FileRepository model.FileRepository
}
// MSConfig will hold repositories that will eventually be injected into
// this service layer
type MSConfig struct {
MessageRepository model.MessageRepository
FileRepository model.FileRepository
}
// NewMessageService is a factory function for
// initializing a UserService with its repository layer dependencies
func NewMessageService(c *MSConfig) model.MessageService {
return &messageService{
MessageRepository: c.MessageRepository,
FileRepository: c.FileRepository,
}
}
func (m *messageService) GetMessages(userId string, channel *model.Channel, cursor string) (*[]model.MessageResponse, error) {
return m.MessageRepository.GetMessages(userId, channel, cursor)
}
func (m *messageService) CreateMessage(params *model.Message) (*model.Message, error) {
params.ID = GenerateId()
return m.MessageRepository.CreateMessage(params)
}
func (m *messageService) UpdateMessage(message *model.Message) error {
return m.MessageRepository.UpdateMessage(message)
}
func (m *messageService) DeleteMessage(message *model.Message) error {
if message.Attachment != nil {
if err := m.FileRepository.DeleteImage(message.Attachment.Filename); err != nil {
log.Printf("Error deleting file from S3: %s", err)
}
}
return m.MessageRepository.DeleteMessage(message)
}
func (m *messageService) UploadFile(header *multipart.FileHeader, channelId string) (*model.Attachment, error) {
filename := formatName(header.Filename)
mimetype := header.Header.Get("Content-Type")
attachment := model.Attachment{
FileType: mimetype,
Filename: filename,
}
attachment.ID = GenerateId()
directory := fmt.Sprintf("channels/%s", channelId)
url, err := m.FileRepository.UploadFile(header, directory, filename, mimetype)
if err != nil {
return nil, err
}
attachment.Url = url
return &attachment, nil
}
func (m *messageService) Get(messageId string) (*model.Message, error) {
return m.MessageRepository.GetById(messageId)
}
var re = regexp.MustCompile(`/[^a-z0-9]/g`)
func formatName(filename string) string {
ext := path.Ext(filename)
id, _ := gonanoid.Nanoid(5)
filename = strings.TrimSuffix(filename, filepath.Ext(filename))
filename = strings.ToLower(filename)
filename = re.ReplaceAllString(filename, "-")
return fmt.Sprintf("%s-%s%s", id, filename, ext)
}
================================================
FILE: server/service/message_service_test.go
================================================
package service
import (
"fmt"
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"testing"
)
func TestGuildService_CreateMessage(t *testing.T) {
t.Run("Success", func(t *testing.T) {
uid := GenerateId()
mockMessage := fixture.GetMockMessage("", "")
params := &model.Message{
UserId: mockMessage.UserId,
ChannelId: mockMessage.ChannelId,
Text: mockMessage.Text,
}
mockMessageRepository := new(mocks.MessageRepository)
ms := NewMessageService(&MSConfig{
MessageRepository: mockMessageRepository,
})
mockMessageRepository.
On("CreateMessage", params).
Run(func(args mock.Arguments) {
mockMessage.ID = uid
}).Return(mockMessage, nil)
message, err := ms.CreateMessage(params)
assert.NoError(t, err)
assert.Equal(t, uid, mockMessage.ID)
assert.Equal(t, message, mockMessage)
mockMessageRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
mockMessage := fixture.GetMockMessage("", "")
params := &model.Message{
UserId: mockMessage.UserId,
ChannelId: mockMessage.ChannelId,
Text: mockMessage.Text,
}
mockMessageRepository := new(mocks.MessageRepository)
ms := NewMessageService(&MSConfig{
MessageRepository: mockMessageRepository,
})
mockErr := apperrors.NewInternal()
mockMessageRepository.
On("CreateMessage", params).
Return(nil, mockErr)
message, err := ms.CreateMessage(params)
assert.EqualError(t, err, mockErr.Error())
assert.Nil(t, message)
})
}
func TestGuildService_DeleteMessage(t *testing.T) {
t.Run("Success", func(t *testing.T) {
mockMessage := fixture.GetMockMessage("", "")
mockMessageRepository := new(mocks.MessageRepository)
ms := NewMessageService(&MSConfig{
MessageRepository: mockMessageRepository,
})
mockMessageRepository.
On("DeleteMessage", mockMessage).
Return(nil)
err := ms.DeleteMessage(mockMessage)
assert.NoError(t, err)
mockMessageRepository.AssertExpectations(t)
})
t.Run("Success with attachment", func(t *testing.T) {
mockMessage := fixture.GetMockMessage("", "")
mockMessage.Attachment = &model.Attachment{
Filename: fixture.RandStr(12),
}
mockMessageRepository := new(mocks.MessageRepository)
mockFileRepository := new(mocks.FileRepository)
ms := NewMessageService(&MSConfig{
MessageRepository: mockMessageRepository,
FileRepository: mockFileRepository,
})
mockFileRepository.On("DeleteImage", mockMessage.Attachment.Filename).Return(nil)
mockMessageRepository.
On("DeleteMessage", mockMessage).
Return(nil)
err := ms.DeleteMessage(mockMessage)
assert.NoError(t, err)
mockMessageRepository.AssertExpectations(t)
mockFileRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
mockMessage := fixture.GetMockMessage("", "")
mockMessageRepository := new(mocks.MessageRepository)
ms := NewMessageService(&MSConfig{
MessageRepository: mockMessageRepository,
})
mockError := apperrors.NewInternal()
mockMessageRepository.
On("DeleteMessage", mockMessage).
Return(mockError)
err := ms.DeleteMessage(mockMessage)
assert.EqualError(t, err, mockError.Error())
mockMessageRepository.AssertExpectations(t)
})
}
func TestMessageService_UploadFile(t *testing.T) {
t.Run("Success", func(t *testing.T) {
imageURL := "https://imageurl.com/jdfkj34kljl"
channelId := fixture.RandID()
id := GenerateId()
multipartImageFixture := fixture.NewMultipartImage("image.png", "image/png")
defer multipartImageFixture.Close()
imageFileHeader := multipartImageFixture.GetFormFile()
directory := fmt.Sprintf("channels/%s", channelId)
attachment := model.Attachment{
FileType: imageFileHeader.Header.Get("Content-Type"),
Filename: formatName("image.png"),
}
uploadFileArgs := mock.Arguments{
imageFileHeader,
directory,
mock.AnythingOfType("string"),
attachment.FileType,
}
mockFileRepository := new(mocks.FileRepository)
mockFileRepository.
On("UploadFile", uploadFileArgs...).
Run(func(args mock.Arguments) {
attachment.ID = id
}).
Return(imageURL, nil)
ms := NewMessageService(&MSConfig{
FileRepository: mockFileRepository,
})
_, err := ms.UploadFile(imageFileHeader, channelId)
assert.NoError(t, err)
mockFileRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
channelId := fixture.RandID()
multipartImageFixture := fixture.NewMultipartImage("image.png", "image/png")
defer multipartImageFixture.Close()
imageFileHeader := multipartImageFixture.GetFormFile()
directory := fmt.Sprintf("channels/%s", channelId)
attachment := model.Attachment{
FileType: imageFileHeader.Header.Get("Content-Type"),
Filename: formatName("image.png"),
}
uploadFileArgs := mock.Arguments{
imageFileHeader,
directory,
mock.AnythingOfType("string"),
attachment.FileType,
}
mockFileRepository := new(mocks.FileRepository)
mockError := apperrors.NewInternal()
mockFileRepository.
On("UploadFile", uploadFileArgs...).
Return("", mockError)
ms := NewMessageService(&MSConfig{
FileRepository: mockFileRepository,
})
att, err := ms.UploadFile(imageFileHeader, channelId)
assert.Error(t, err)
assert.Equal(t, err, apperrors.NewInternal())
assert.Nil(t, att)
mockFileRepository.AssertExpectations(t)
})
}
================================================
FILE: server/service/password.go
================================================
package service
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"golang.org/x/crypto/scrypt"
)
// hashPassword hashes the given password using bcrypt
func hashPassword(password string) (string, error) {
salt := make([]byte, 32)
_, err := rand.Read(salt)
if err != nil {
return "", err
}
// using recommended cost parameters from - https://godoc.org/golang.org/x/crypto/scrypt
shash, err := scrypt.Key([]byte(password), salt, 32768, 8, 1, 32)
if err != nil {
return "", err
}
// return hex-encoded string with salt appended to password
hashedPW := fmt.Sprintf("%s.%s", hex.EncodeToString(shash), hex.EncodeToString(salt))
return hashedPW, nil
}
// comparePasswords compares the stored password with the supplied one
func comparePasswords(storedPassword string, suppliedPassword string) (bool, error) {
pwsalt := strings.Split(storedPassword, ".")
if len(pwsalt) < 2 {
return false, fmt.Errorf("did not provide a valid hash")
}
// check supplied password salted with hash
salt, err := hex.DecodeString(pwsalt[1])
if err != nil {
return false, fmt.Errorf("unable to verify user password")
}
shash, err := scrypt.Key([]byte(suppliedPassword), salt, 32768, 8, 1, 32)
if err != nil {
return false, fmt.Errorf("unable to verify user password")
}
return hex.EncodeToString(shash) == pwsalt[0], nil
}
================================================
FILE: server/service/password_test.go
================================================
package service
import (
"github.com/sentrionic/valkyrie/model/fixture"
"testing"
"github.com/stretchr/testify/require"
)
func TestPassword(t *testing.T) {
password := fixture.RandStringRunes(10)
hashedPassword1, err := hashPassword(password)
require.NoError(t, err)
require.NotEmpty(t, hashedPassword1)
valid, err := comparePasswords(hashedPassword1, password)
require.NoError(t, err)
require.True(t, valid)
wrongPassword := fixture.RandStringRunes(10)
valid, err = comparePasswords(hashedPassword1, wrongPassword)
require.NoError(t, err)
require.False(t, valid)
hashedPassword2, err := hashPassword(password)
require.NoError(t, err)
require.NotEmpty(t, hashedPassword2)
require.NotEqual(t, hashedPassword1, hashedPassword2)
valid, err = comparePasswords(password, hashedPassword1)
require.Error(t, err)
require.EqualError(t, err, "did not provide a valid hash")
require.False(t, valid)
}
================================================
FILE: server/service/socket_service.go
================================================
package service
import (
"encoding/json"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/ws"
"log"
)
type socketService struct {
Hub ws.Hub
GuildRepository model.GuildRepository
ChannelRepository model.ChannelRepository
}
// SSConfig will hold repositories that will eventually be injected into
// this service layer
type SSConfig struct {
Hub ws.Hub
GuildRepository model.GuildRepository
ChannelRepository model.ChannelRepository
}
// NewSocketService is a factory function for
// initializing a SocketService with its repository layer dependencies
func NewSocketService(c *SSConfig) model.SocketService {
return &socketService{
Hub: c.Hub,
GuildRepository: c.GuildRepository,
ChannelRepository: c.ChannelRepository,
}
}
func (s *socketService) EmitNewMessage(room string, message *model.MessageResponse) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.NewMessageAction,
Data: message,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, room)
}
func (s *socketService) EmitEditMessage(room string, message *model.MessageResponse) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.EditMessageAction,
Data: message,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, room)
}
func (s *socketService) EmitDeleteMessage(room, messageId string) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.DeleteMessageAction,
Data: messageId,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, room)
}
func (s *socketService) EmitNewChannel(room string, channel *model.ChannelResponse) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.AddChannelAction,
Data: channel,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, room)
}
func (s *socketService) EmitNewPrivateChannel(members []string, channel *model.ChannelResponse) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.AddPrivateChannelAction,
Data: channel,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
for _, id := range members {
s.Hub.BroadcastToRoom(data, id)
}
}
func (s *socketService) EmitEditChannel(room string, channel *model.ChannelResponse) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.EditChannelAction,
Data: channel,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, room)
}
func (s *socketService) EmitDeleteChannel(channel *model.Channel) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.DeleteChannelAction,
Data: channel.ID,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, *channel.GuildID)
}
func (s *socketService) EmitEditGuild(guild *model.Guild) {
response := guild.SerializeGuild("")
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.EditGuildAction,
Data: response,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
members, err := s.GuildRepository.GetMemberIds(guild.ID)
if err != nil {
log.Printf("error getting member ids: %v\n", err)
}
for _, id := range *members {
s.Hub.BroadcastToRoom(data, id)
}
}
func (s *socketService) EmitDeleteGuild(guildId string, members []string) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.DeleteGuildAction,
Data: guildId,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
for _, id := range members {
s.Hub.BroadcastToRoom(data, id)
}
}
func (s *socketService) EmitRemoveFromGuild(memberId, guildId string) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.RemoveFromGuildAction,
Data: guildId,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, memberId)
}
func (s *socketService) EmitAddMember(room string, member *model.User) {
response := model.MemberResponse{
Id: member.ID,
Username: member.Username,
Image: member.Image,
IsOnline: member.IsOnline,
CreatedAt: member.CreatedAt,
UpdatedAt: member.UpdatedAt,
IsFriend: false,
}
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.AddMemberAction,
Data: response,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, room)
}
func (s *socketService) EmitRemoveMember(room, memberId string) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.RemoveMemberAction,
Data: memberId,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, room)
}
func (s *socketService) EmitNewDMNotification(channelId string, user *model.User) {
response := model.DirectMessage{
Id: channelId,
User: model.DMUser{
Id: user.ID,
Username: user.Username,
Image: user.Image,
IsOnline: user.IsOnline,
IsFriend: false,
},
}
notification, err := json.Marshal(model.WebsocketMessage{
Action: ws.NewDMNotificationAction,
Data: response,
})
if err != nil {
log.Printf("error marshalling notification: %v\n", err)
}
pushToTop, err := json.Marshal(model.WebsocketMessage{
Action: ws.PushToTopAction,
Data: channelId,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
members, err := s.ChannelRepository.GetDMMemberIds(channelId)
if err != nil {
log.Printf("error getting member ids: %v\n", err)
}
for _, id := range *members {
if id != user.ID {
s.Hub.BroadcastToRoom(notification, id)
}
s.Hub.BroadcastToRoom(pushToTop, id)
}
}
func (s *socketService) EmitNewNotification(guildId, channelId string) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.NewNotificationAction,
Data: guildId,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
members, err := s.GuildRepository.GetMemberIds(guildId)
if err != nil {
log.Printf("error getting member ids: %v\n", err)
}
for _, id := range *members {
s.Hub.BroadcastToRoom(data, id)
}
notification, err := json.Marshal(model.WebsocketMessage{
Action: ws.NewNotificationAction,
Data: channelId,
})
if err != nil {
log.Printf("error marshalling notification: %v\n", err)
}
s.Hub.BroadcastToRoom(notification, guildId)
}
func (s *socketService) EmitSendRequest(room string) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.SendRequestAction,
Data: "",
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, room)
}
func (s *socketService) EmitAddFriendRequest(room string, request *model.FriendRequest) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.AddRequestAction,
Data: request,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, room)
s.EmitSendRequest(room)
}
func (s *socketService) EmitAddFriend(user, member *model.User) {
userResponse := model.Friend{
Id: user.ID,
Username: user.Username,
Image: user.Image,
IsOnline: user.IsOnline,
}
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.AddFriendAction,
Data: userResponse,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, member.ID)
memberResponse := model.Friend{
Id: member.ID,
Username: member.Username,
Image: member.Image,
IsOnline: member.IsOnline,
}
data, err = json.Marshal(model.WebsocketMessage{
Action: ws.AddFriendAction,
Data: memberResponse,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, user.ID)
}
func (s *socketService) EmitRemoveFriend(userId, memberId string) {
data, err := json.Marshal(model.WebsocketMessage{
Action: ws.RemoveFriendAction,
Data: memberId,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, userId)
data, err = json.Marshal(model.WebsocketMessage{
Action: ws.RemoveFriendAction,
Data: userId,
})
if err != nil {
log.Printf("error marshalling response: %v\n", err)
}
s.Hub.BroadcastToRoom(data, memberId)
}
================================================
FILE: server/service/user_service.go
================================================
package service
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"log"
"mime/multipart"
"strings"
)
// UserService acts as a struct for injecting an implementation of UserRepository
// for use in service methods
type userService struct {
UserRepository model.UserRepository
FileRepository model.FileRepository
RedisRepository model.RedisRepository
MailRepository model.MailRepository
}
// USConfig will hold repositories that will eventually be injected into
// this service layer
type USConfig struct {
UserRepository model.UserRepository
FileRepository model.FileRepository
RedisRepository model.RedisRepository
MailRepository model.MailRepository
}
// NewUserService is a factory function for
// initializing a UserService with its repository layer dependencies
func NewUserService(c *USConfig) model.UserService {
return &userService{
UserRepository: c.UserRepository,
FileRepository: c.FileRepository,
RedisRepository: c.RedisRepository,
MailRepository: c.MailRepository,
}
}
// Get retrieves a user based on their uid
func (s *userService) Get(uid string) (*model.User, error) {
return s.UserRepository.FindByID(uid)
}
// GetByEmail retrieves a user based on their email
func (s *userService) GetByEmail(email string) (*model.User, error) {
// Sanitize email
email = strings.ToLower(email)
email = strings.TrimSpace(email)
return s.UserRepository.FindByEmail(email)
}
// Register creates a user
func (s *userService) Register(user *model.User) (*model.User, error) {
hashedPassword, err := hashPassword(user.Password)
if err != nil {
log.Printf("Unable to signup user for email: %v\n", user.Email)
return nil, apperrors.NewInternal()
}
user.ID = GenerateId()
user.Image = generateAvatar(user.Email)
user.Password = hashedPassword
return s.UserRepository.Create(user)
}
// Login reaches out to the UserRepository check if the user exists
// and then compares the supplied password with the provided password
// if a valid email/password combo is provided, u will hold all
// available user fields
func (s *userService) Login(email, password string) (*model.User, error) {
user, err := s.UserRepository.FindByEmail(email)
// Will return NotAuthorized to client to omit details of why
if err != nil {
return nil, apperrors.NewAuthorization(apperrors.InvalidCredentials)
}
// verify
match, err := comparePasswords(user.Password, password)
if err != nil {
return nil, apperrors.NewInternal()
}
if !match {
return nil, apperrors.NewAuthorization(apperrors.InvalidCredentials)
}
return user, nil
}
func (s *userService) UpdateAccount(u *model.User) error {
return s.UserRepository.Update(u)
}
func (s *userService) IsEmailAlreadyInUse(email string) bool {
user, err := s.UserRepository.FindByEmail(email)
if err != nil {
return true
}
return user.ID != ""
}
func (s *userService) ChangeAvatar(header *multipart.FileHeader, directory string) (string, error) {
return s.FileRepository.UploadAvatar(header, directory)
}
func (s *userService) DeleteImage(key string) error {
return s.FileRepository.DeleteImage(key)
}
func (s *userService) ChangePassword(currentPassword, newPassword string, user *model.User) error {
// verify
match, err := comparePasswords(user.Password, currentPassword)
if err != nil {
return apperrors.NewInternal()
}
if !match {
return apperrors.NewAuthorization(apperrors.InvalidOldPassword)
}
hashedPassword, err := hashPassword(newPassword)
if err != nil {
log.Printf("Unable to change password for email: %v\n", user.Email)
return apperrors.NewInternal()
}
user.Password = hashedPassword
return s.UserRepository.Update(user)
}
func (s *userService) ForgotPassword(ctx context.Context, user *model.User) error {
token, err := s.RedisRepository.SetResetToken(ctx, user.ID)
if err != nil {
return err
}
return s.MailRepository.SendResetMail(user.Email, token)
}
func (s *userService) ResetPassword(ctx context.Context, password string, token string) (*model.User, error) {
id, err := s.RedisRepository.GetIdFromToken(ctx, token)
if err != nil {
return nil, err
}
user, err := s.UserRepository.FindByID(id)
if err != nil {
return nil, err
}
hashedPassword, err := hashPassword(password)
if err != nil {
log.Printf("Unable to reset password")
return nil, apperrors.NewInternal()
}
user.Password = hashedPassword
if err = s.UserRepository.Update(user); err != nil {
return nil, err
}
return user, nil
}
func (s *userService) GetFriendAndGuildIds(userId string) (*[]string, error) {
return s.UserRepository.GetFriendAndGuildIds(userId)
}
func (s *userService) GetRequestCount(userId string) (*int64, error) {
return s.UserRepository.GetRequestCount(userId)
}
// generateAvatar returns a gravatar using the md5 hash of the email
func generateAvatar(email string) string {
hash := md5.Sum([]byte(email))
return fmt.Sprintf("https://gravatar.com/avatar/%s?d=identicon", hex.EncodeToString(hash[:]))
}
================================================
FILE: server/service/user_service_test.go
================================================
package service
import (
"context"
"fmt"
"github.com/sentrionic/valkyrie/mocks"
"github.com/sentrionic/valkyrie/model"
"github.com/sentrionic/valkyrie/model/apperrors"
"github.com/sentrionic/valkyrie/model/fixture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"testing"
)
func TestGet(t *testing.T) {
t.Run("Success", func(t *testing.T) {
uid := GenerateId()
mockUser := fixture.GetMockUser()
mockUser.ID = uid
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
})
mockUserRepository.On("FindByID", uid).Return(mockUser, nil)
u, err := us.Get(uid)
assert.NoError(t, err)
assert.Equal(t, u, mockUser)
mockUserRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
uid := GenerateId()
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
})
mockUserRepository.On("FindByID", uid).Return(nil, fmt.Errorf("some error down the call chain"))
u, err := us.Get(uid)
assert.Nil(t, u)
assert.Error(t, err)
mockUserRepository.AssertExpectations(t)
})
}
func TestUserService_GetByEmail(t *testing.T) {
t.Run("Success", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
})
mockUserRepository.On("FindByEmail", mockUser.Email).Return(mockUser, nil)
u, err := us.GetByEmail(mockUser.Email)
assert.NoError(t, err)
assert.Equal(t, u, mockUser)
mockUserRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
email := fixture.Email()
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
})
mockUserRepository.On("FindByEmail", email).Return(nil, fmt.Errorf("some error down the call chain"))
u, err := us.GetByEmail(email)
assert.Nil(t, u)
assert.Error(t, err)
mockUserRepository.AssertExpectations(t)
})
}
func TestRegister(t *testing.T) {
t.Run("Success", func(t *testing.T) {
uid := GenerateId()
mockUser := fixture.GetMockUser()
initial := &model.User{
Username: mockUser.Username,
Email: mockUser.Email,
Password: mockUser.Password,
}
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
})
// We can use Run method to modify the user when the Create method is called.
// We can then chain on a Return method to return no error
mockUserRepository.
On("Create", initial).
Run(func(args mock.Arguments) {
mockUser.ID = uid
}).Return(mockUser, nil)
user, err := us.Register(initial)
assert.NoError(t, err)
// assert user now has a userID
assert.Equal(t, uid, mockUser.ID)
assert.Equal(t, user, mockUser)
mockUserRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
mockUser := &model.User{
Email: "bob@bob.com",
Username: "bobby",
Password: "howdyhoneighbor!",
}
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
})
mockErr := apperrors.NewConflict("email", "bob@bob.com")
// We can use Run method to modify the user when the Create method is called.
// We can then chain on a Return method to return no error
mockUserRepository.
On("Create", mockUser).
Return(nil, mockErr)
user, err := us.Register(mockUser)
// assert error is error we response with in mock
assert.EqualError(t, err, mockErr.Error())
assert.Nil(t, user)
mockUserRepository.AssertExpectations(t)
})
}
func TestLogin(t *testing.T) {
// setup valid email/pw combo with hashed password to test method
// response when provided password is invalid
validPW := "howdyhoneighbor!"
hashedValidPW, _ := hashPassword(validPW)
invalidPW := "howdyhodufus!"
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
})
t.Run("Success", func(t *testing.T) {
mockUser := fixture.GetMockUser()
mockUser.Password = hashedValidPW
mockUserRepository.
On("FindByEmail", mockUser.Email).Return(mockUser, nil)
user, err := us.Login(mockUser.Email, validPW)
assert.NoError(t, err)
assert.Equal(t, user, mockUser)
mockUserRepository.AssertCalled(t, "FindByEmail", mockUser.Email)
})
t.Run("Invalid email/password combination", func(t *testing.T) {
uid := GenerateId()
mockUserResp := fixture.GetMockUser()
mockUserResp.ID = uid
mockUserResp.Password = hashedValidPW
mockArgs := mock.Arguments{
mockUserResp.Email,
}
// We can use Run method to modify the user when the Create method is called.
// We can then chain on a Return method to return no error
mockUserRepository.
On("FindByEmail", mockArgs...).Return(mockUserResp, nil)
user, err := us.Login(mockUserResp.Email, invalidPW)
assert.Error(t, err)
assert.EqualError(t, err, apperrors.InvalidCredentials)
assert.Nil(t, user)
mockUserRepository.AssertCalled(t, "FindByEmail", mockArgs...)
})
}
func TestUpdateDetails(t *testing.T) {
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
})
t.Run("Success", func(t *testing.T) {
uid := GenerateId()
mockUser := fixture.GetMockUser()
mockUser.ID = uid
mockArgs := mock.Arguments{
mockUser,
}
mockUserRepository.
On("Update", mockArgs...).Return(nil)
err := us.UpdateAccount(mockUser)
assert.NoError(t, err)
mockUserRepository.AssertCalled(t, "Update", mockArgs...)
})
t.Run("Failure", func(t *testing.T) {
uid := GenerateId()
mockUser := fixture.GetMockUser()
mockUser.ID = uid
mockArgs := mock.Arguments{
mockUser,
}
mockError := apperrors.NewInternal()
mockUserRepository.
On("Update", mockArgs...).Return(mockError)
err := us.UpdateAccount(mockUser)
assert.Error(t, err)
apperror, ok := err.(*apperrors.Error)
assert.True(t, ok)
assert.Equal(t, apperrors.Internal, apperror.Type)
mockUserRepository.AssertCalled(t, "Update", mockArgs...)
})
}
func TestUserService_ChangeAvatar(t *testing.T) {
mockUserRepository := new(mocks.UserRepository)
mockFileRepository := new(mocks.FileRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
FileRepository: mockFileRepository,
})
t.Run("Successful new image", func(t *testing.T) {
uid := GenerateId()
// does not have have imageURL
mockUser := fixture.GetMockUser()
mockUser.ID = uid
mockUser.Image = ""
multipartImageFixture := fixture.NewMultipartImage("image.png", "image/png")
defer multipartImageFixture.Close()
imageFileHeader := multipartImageFixture.GetFormFile()
directory := "test_dir"
uploadFileArgs := mock.Arguments{
imageFileHeader,
directory,
}
imageURL := "https://imageurl.com/jdfkj34kljl"
mockFileRepository.
On("UploadAvatar", uploadFileArgs...).
Return(imageURL, nil)
updateArgs := mock.Arguments{
mockUser,
}
mockUpdatedUser := &model.User{
BaseModel: model.BaseModel{
ID: mockUser.ID,
CreatedAt: mockUser.CreatedAt,
UpdatedAt: mockUser.UpdatedAt,
},
Email: mockUser.Email,
Username: mockUser.Username,
Image: imageURL,
Password: mockUser.Password,
}
mockUserRepository.
On("Update", updateArgs...).
Return(nil)
url, err := us.ChangeAvatar(imageFileHeader, directory)
assert.NoError(t, err)
mockUser.Image = url
err = us.UpdateAccount(mockUser)
assert.NoError(t, err)
assert.Equal(t, mockUpdatedUser, mockUser)
mockFileRepository.AssertCalled(t, "UploadAvatar", uploadFileArgs...)
mockUserRepository.AssertCalled(t, "Update", updateArgs...)
})
t.Run("Successful update image", func(t *testing.T) {
imageURL := "https://imageurl.com/jdfkj34kljl"
uid := GenerateId()
mockUser := &model.User{
Email: "new@bob.com",
Username: "NewRobert",
Image: imageURL,
}
mockUser.ID = uid
multipartImageFixture := fixture.NewMultipartImage("image.png", "image/png")
defer multipartImageFixture.Close()
imageFileHeader := multipartImageFixture.GetFormFile()
directory := "test_dir"
uploadFileArgs := mock.Arguments{
imageFileHeader,
directory,
}
deleteImageArgs := mock.Arguments{
imageURL,
}
mockFileRepository.
On("UploadAvatar", uploadFileArgs...).
Return(imageURL, nil)
mockFileRepository.
On("DeleteImage", deleteImageArgs...).
Return(nil)
mockUpdatedUser := &model.User{
Email: "new@bob.com",
Username: "NewRobert",
Image: imageURL,
}
mockUpdatedUser.ID = uid
updateArgs := mock.Arguments{
mockUser,
}
mockUserRepository.
On("Update", updateArgs...).
Return(nil)
url, err := us.ChangeAvatar(imageFileHeader, directory)
assert.NoError(t, err)
err = us.DeleteImage(mockUser.Image)
assert.NoError(t, err)
mockUser.Image = url
err = us.UpdateAccount(mockUser)
assert.NoError(t, err)
assert.Equal(t, mockUpdatedUser, mockUser)
mockFileRepository.AssertCalled(t, "UploadAvatar", uploadFileArgs...)
mockFileRepository.AssertCalled(t, "DeleteImage", imageURL)
mockUserRepository.AssertCalled(t, "Update", updateArgs...)
})
t.Run("FileRepository Error", func(t *testing.T) {
// need to create a new UserService and repository
// because testify has no way to overwrite a mock's
// "On" call.
mockUserRepository := new(mocks.UserRepository)
mockFileRepository := new(mocks.FileRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
FileRepository: mockFileRepository,
})
multipartImageFixture := fixture.NewMultipartImage("image.png", "image/png")
defer multipartImageFixture.Close()
imageFileHeader := multipartImageFixture.GetFormFile()
directory := "file_directory"
uploadFileArgs := mock.Arguments{
imageFileHeader,
directory,
}
mockError := apperrors.NewInternal()
mockFileRepository.
On("UploadAvatar", uploadFileArgs...).
Return("", mockError)
url, err := us.ChangeAvatar(imageFileHeader, directory)
assert.Equal(t, "", url)
assert.Error(t, err)
mockFileRepository.AssertCalled(t, "UploadAvatar", uploadFileArgs...)
mockUserRepository.AssertNotCalled(t, "Update")
})
t.Run("UserRepository UpdateImage Error", func(t *testing.T) {
uid := GenerateId()
imageURL := "https://imageurl.com/jdfkj34kljl"
// has imageURL
mockUser := &model.User{
Email: "new@bob.com",
Username: "A New Bob!",
Image: imageURL,
}
mockUser.ID = uid
multipartImageFixture := fixture.NewMultipartImage("image.png", "image/png")
defer multipartImageFixture.Close()
imageFileHeader := multipartImageFixture.GetFormFile()
directory := "file_dir"
uploadFileArgs := mock.Arguments{
imageFileHeader,
directory,
}
mockFileRepository.
On("UploadAvatar", uploadFileArgs...).
Return(imageURL, nil)
updateArgs := mock.Arguments{
mockUser,
}
mockError := apperrors.NewInternal()
mockUserRepository.
On("Update", updateArgs...).
Return(mockError)
url, err := us.ChangeAvatar(imageFileHeader, directory)
assert.NoError(t, err)
assert.Equal(t, imageURL, url)
err = us.UpdateAccount(mockUser)
assert.Error(t, err)
mockFileRepository.AssertCalled(t, "UploadAvatar", uploadFileArgs...)
mockUserRepository.AssertCalled(t, "Update", updateArgs...)
})
}
func TestUserService_ChangePassword(t *testing.T) {
t.Run("Success", func(t *testing.T) {
mockUser := fixture.GetMockUser()
currentPassword := mockUser.Password
hashedPassword, err := hashPassword(currentPassword)
assert.NoError(t, err)
mockUser.Password = hashedPassword
newPassword := fixture.RandStringRunes(10)
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{UserRepository: mockUserRepository})
mockUserRepository.On("Update", mockUser).Return(nil)
err = us.ChangePassword(currentPassword, newPassword, mockUser)
assert.NoError(t, err)
assert.NotEqual(t, mockUser.Password, newPassword)
assert.NotEqual(t, mockUser.Password, hashedPassword)
mockUserRepository.AssertExpectations(t)
})
t.Run("Error verifying password", func(t *testing.T) {
mockUser := fixture.GetMockUser()
currentPassword := mockUser.Password
newPassword := fixture.RandStringRunes(10)
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{UserRepository: mockUserRepository})
err := us.ChangePassword(currentPassword, newPassword, mockUser)
assert.Error(t, err)
assert.Equal(t, err, apperrors.NewInternal())
mockUserRepository.AssertNotCalled(t, "Update")
})
t.Run("Current Password is incorrect", func(t *testing.T) {
mockUser := fixture.GetMockUser()
currentPassword := fixture.RandStringRunes(10)
hashedPassword, err := hashPassword(mockUser.Password)
assert.NoError(t, err)
mockUser.Password = hashedPassword
newPassword := fixture.RandStringRunes(10)
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{UserRepository: mockUserRepository})
err = us.ChangePassword(currentPassword, newPassword, mockUser)
assert.Error(t, err)
assert.Equal(t, err, apperrors.NewAuthorization(apperrors.InvalidOldPassword))
mockUserRepository.AssertNotCalled(t, "Update")
})
t.Run("Error returned from the repository", func(t *testing.T) {
mockUser := fixture.GetMockUser()
currentPassword := mockUser.Password
hashedPassword, err := hashPassword(currentPassword)
assert.NoError(t, err)
mockUser.Password = hashedPassword
newPassword := fixture.RandStringRunes(10)
mockUserRepository := new(mocks.UserRepository)
us := NewUserService(&USConfig{UserRepository: mockUserRepository})
mockError := apperrors.NewInternal()
mockUserRepository.On("Update", mockUser).Return(mockError)
err = us.ChangePassword(currentPassword, newPassword, mockUser)
assert.Error(t, err)
mockUserRepository.AssertExpectations(t)
})
}
func TestUserService_ForgotPassword(t *testing.T) {
mockUser := fixture.GetMockUser()
token := fixture.RandStringRunes(10)
t.Run("Success", func(t *testing.T) {
mockRedisRepository := new(mocks.RedisRepository)
mockMailRepository := new(mocks.MailRepository)
us := NewUserService(&USConfig{
RedisRepository: mockRedisRepository,
MailRepository: mockMailRepository,
})
mockRedisRepository.On("SetResetToken", mock.Anything, mockUser.ID).Return(token, nil)
mockMailRepository.On("SendResetMail", mockUser.Email, token).Return(nil)
err := us.ForgotPassword(context.TODO(), mockUser)
assert.NoError(t, err)
mockRedisRepository.AssertExpectations(t)
mockMailRepository.AssertExpectations(t)
})
t.Run("Error", func(t *testing.T) {
mockRedisRepository := new(mocks.RedisRepository)
mockMailRepository := new(mocks.MailRepository)
us := NewUserService(&USConfig{
RedisRepository: mockRedisRepository,
MailRepository: mockMailRepository,
})
mockError := apperrors.NewInternal()
mockRedisRepository.On("SetResetToken", mock.Anything, mockUser.ID).Return("", mockError)
err := us.ForgotPassword(context.TODO(), mockUser)
assert.Error(t, err)
mockRedisRepository.AssertExpectations(t)
mockMailRepository.AssertNotCalled(t, "SendResetMail")
})
}
func TestUserService_ResetPassword(t *testing.T) {
mockUser := fixture.GetMockUser()
password := fixture.RandStr(10)
token := fixture.RandStr(10)
t.Run("Success", func(t *testing.T) {
mockUserRepository := new(mocks.UserRepository)
mockRedisRepository := new(mocks.RedisRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
RedisRepository: mockRedisRepository,
})
mockRedisRepository.On("GetIdFromToken", mock.Anything, token).Return(mockUser.ID, nil)
mockUserRepository.On("FindByID", mockUser.ID).Return(mockUser, nil)
mockUserRepository.On("Update", mockUser).Return(nil)
user, err := us.ResetPassword(context.TODO(), password, token)
assert.NoError(t, err)
assert.NotNil(t, user)
mockUserRepository.AssertExpectations(t)
mockRedisRepository.AssertExpectations(t)
})
t.Run("No id found", func(t *testing.T) {
mockUserRepository := new(mocks.UserRepository)
mockRedisRepository := new(mocks.RedisRepository)
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
RedisRepository: mockRedisRepository,
})
mockError := apperrors.NewInternal()
mockRedisRepository.On("GetIdFromToken", mock.Anything, token).Return("", mockError)
user, err := us.ResetPassword(context.TODO(), password, token)
assert.Error(t, err)
assert.Nil(t, user)
mockRedisRepository.AssertCalled(t, "GetIdFromToken", mock.Anything, token)
mockUserRepository.AssertNotCalled(t, "FindByID")
mockUserRepository.AssertNotCalled(t, "Update")
})
t.Run("No user found", func(t *testing.T) {
mockUserRepository := new(mocks.UserRepository)
mockRedisRepository := new(mocks.RedisRepository)
id := fixture.RandID()
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
RedisRepository: mockRedisRepository,
})
mockError := apperrors.NewInternal()
mockRedisRepository.On("GetIdFromToken", mock.Anything, token).Return(id, nil)
mockUserRepository.On("FindByID", id).Return(nil, mockError)
user, err := us.ResetPassword(context.TODO(), password, token)
assert.Error(t, err)
assert.Nil(t, user)
mockRedisRepository.AssertCalled(t, "GetIdFromToken", mock.Anything, token)
mockUserRepository.AssertCalled(t, "FindByID", id)
mockUserRepository.AssertNotCalled(t, "Update")
})
t.Run("Error returned from the repository", func(t *testing.T) {
mockUserRepository := new(mocks.UserRepository)
mockRedisRepository := new(mocks.RedisRepository)
mockError := apperrors.NewInternal()
us := NewUserService(&USConfig{
UserRepository: mockUserRepository,
RedisRepository: mockRedisRepository,
})
mockRedisRepository.On("GetIdFromToken", mock.Anything, token).Return(mockUser.ID, nil)
mockUserRepository.On("FindByID", mockUser.ID).Return(mockUser, nil)
mockUserRepository.On("Update", mockUser).Return(mockError)
user, err := us.ResetPassword(context.TODO(), password, token)
assert.Error(t, err)
assert.Nil(t, user)
mockUserRepository.AssertExpectations(t)
mockRedisRepository.AssertExpectations(t)
})
}
================================================
FILE: server/static/asyncapi.yml
================================================
asyncapi: '2.4.0'
info:
title: Valkyrie Websockets
version: '1.0.0'
description: >
This service is in charge of processing websocket events. Websockets are authenticated using sessions. All received messages must be specified like this:
| { "action": "joinRoom", "room": "123456789", "message": "username"} |.
Room is required to join a channel room, message can be used for additional arguments or information. Both are optional.
Emited messages are of form | { "action": "new_message", "data": object } |.
servers:
production:
url: wss://api.valkyrieapp.xyz/ws
protocol: wss
development:
url: ws://localhost:4000/ws
protocol: ws
channels:
/:
publish:
message:
oneOf:
- $ref: '#/components/messages/toggleOnline'
- $ref: '#/components/messages/toggleOffline'
- $ref: '#/components/messages/joinUser'
- $ref: '#/components/messages/joinChannel'
- $ref: '#/components/messages/joinGuild'
- $ref: '#/components/messages/startTyping'
- $ref: '#/components/messages/stopTyping'
- $ref: '#/components/messages/getRequestCount'
- $ref: '#/components/messages/leaveGuild'
- $ref: '#/components/messages/leaveRoom'
subscribe:
message:
oneOf:
- $ref: '#/components/messages/addChannel'
- $ref: '#/components/messages/deleteChannel'
- $ref: '#/components/messages/editChannel'
- $ref: '#/components/messages/editGuild'
- $ref: '#/components/messages/deleteGuild'
- $ref: '#/components/messages/addMember'
- $ref: '#/components/messages/removeMember'
- $ref: '#/components/messages/new_message'
- $ref: '#/components/messages/edit_message'
- $ref: '#/components/messages/delete_message'
- $ref: '#/components/messages/push_to_top'
- $ref: '#/components/messages/new_notification'
- $ref: '#/components/messages/toggle_online'
- $ref: '#/components/messages/toggle_offline'
- $ref: '#/components/messages/addToTyping'
- $ref: '#/components/messages/removeFromTyping'
- $ref: '#/components/messages/send_request'
- $ref: '#/components/messages/add_friend'
- $ref: '#/components/messages/remove_friend'
- $ref: '#/components/messages/requestCount'
components:
securitySchemes:
session:
type: httpApiKey
name: token
in: query
schemas:
attachment:
type: object
properties:
fallback:
type: string
color:
type: string
pretext:
type: string
author_name:
type: string
author_link:
type: string
format: uri
author_icon:
type: string
format: uri
title:
type: string
title_link:
type: string
format: uri
text:
type: string
fields:
type: array
items:
type: object
properties:
title:
type: string
value:
type: string
short:
type: boolean
image_url:
type: string
format: uri
thumb_url:
type: string
format: uri
footer:
type: string
footer_icon:
type: string
format: uri
ts:
type: number
messages:
addChannel:
summary: 'A channel was created.'
payload:
type: object
description: 'see ChannelResponse'
properties:
id:
type: string
name:
type: string
isPublic:
type: boolean
createdAt:
type: string
updatedAt:
type: string
hasNotification:
type: boolean
deleteChannel:
summary: 'A channel was deleted.'
payload:
type: string
properties:
id:
type: string
editChannel:
summary: 'A channel was edited'
payload:
type: object
description: 'see ChannelResponse'
properties:
id:
type: string
name:
type: string
isPublic:
type: boolean
createdAt:
type: string
updatedAt:
type: string
hasNotification:
type: boolean
editGuild:
summary: 'A guild was edited'
payload:
type: object
description: 'see Guild'
properties:
name:
type: string
icon:
type: string
deleteGuild:
summary: 'A guild was deleted.'
payload:
type: string
properties:
id:
type: string
addMember:
summary: 'A member got added to the guild.'
payload:
type: object
description: 'see MemberResponse'
properties:
id:
type: string
username:
type: string
image:
type: string
isOnline:
type: string
createdAt:
type: string
updatedAt:
type: string
isFriend:
type: string
removeMember:
summary: 'A member was removed from the guild.'
payload:
type: string
properties:
id:
type: string
new_message:
summary: 'A new message was sent to a channel.'
payload:
type: object
properties:
user:
type: object
description: see MemberResponse
id:
type: string
text:
type: string
url:
type: string
filetype:
type: string
createdAt:
type: string
updatedAt:
type: string
edit_message:
summary: 'A message in this channel was edited.'
payload:
type: object
properties:
user:
type: object
description: see MemberResponse
id:
type: string
text:
type: string
url:
type: string
filetype:
type: string
createdAt:
type: string
updatedAt:
type: string
delete_message:
summary: 'A message in this channel was deleted.'
payload:
type: string
properties:
id:
type: string
push_to_top:
summary: 'A notification that pushes the DM to the top of the list.'
payload:
type: string
properties:
dmChannelId:
type: string
toggle_online:
summary: 'A notification that the user went online. Gets emited to guild members that currently view the guild and friends of the user.'
payload:
type: string
properties:
userId:
type: string
toggle_offline:
summary: 'A notification that the user went offline. Gets emited to guild members that currently view the guild and friends of the user.'
payload:
type: string
properties:
userId:
type: string
new_notification:
summary: 'A new message notification, published to all guild members. Additionally sends the channelId to members that currently view the guild.'
payload:
type: string
properties:
channelId:
type: string
guildId:
type: string
addToTyping:
summary: 'Emits the username to the channel the user is currently typing in.'
payload:
type: string
properties:
username:
type: string
removeFromTyping:
summary: 'Emits the username to the channel the user was typing in.'
payload:
type: string
properties:
username:
type: string
send_request:
summary: 'Emits a notification that a friends request was received'
add_friend:
summary: 'Adds the added person to the friends list.'
payload:
type: object
description: 'see MemberResponse'
properties:
member:
type: object
remove_friend:
summary: 'Removes the former friend from the friends list.'
payload:
type: string
properties:
id:
type: string
requestCount:
summary: 'The amount of friends requests the user has'
payload:
type: number
properties:
count:
type: number
toggleOnline:
summary: 'Changes the users status to online and broadcasts it to all friends and guilds they are part of.'
toggleOffline:
summary: 'Changes the users status to offline and broadcasts it to all friends and guilds they are part of. Leaves all connected rooms.'
joinUser:
summary: 'Joins the users room. This room receives guild, DM & friend notifications'
payload:
type: string
properties:
userId:
type: string
joinChannel:
summary: 'Joins the channels room. Checks if the user is a member of said channel. Receives message & typing events.'
payload:
type: string
properties:
channelId:
type: string
joinGuild:
summary: 'Joins the guilds room. Requires member access. Receives guild member & channel events.'
payload:
type: string
properties:
guildId:
type: string
startTyping:
summary: 'Emits the username to the channel they are typing in.'
payload:
type: string
properties:
channelId:
type: string
username:
type: string
stopTyping:
summary: 'Removes the username from the channel they were typing in.'
payload:
type: string
properties:
channelId:
type: string
username:
type: string
getRequestCount:
summary: 'Gets the amount of friend requests the user has.'
leaveGuild:
summary: 'Leaves the guild room.'
payload:
type: string
properties:
guildId:
type: string
leaveRoom:
summary: 'Leaves the room.'
payload:
type: string
properties:
roomId:
type: string
================================================
FILE: server/static/index.html
================================================
Valkyrie Websockets
1.0.0
documentation
Valkyrie Websockets
1.0.0
This service is in charge of processing websocket events. Websockets are authenticated using sessions. All messages send to the server must be specified like this:
{
"action": "joinRoom",
"room": "123456789",
"message": "username"
}
Room is required to join a channel room, message can be used for additional arguments or information. Both are optional. Messages emitted from the server are of form:
{
"action": "new_message",
"message": {
... properties
}
}
Servers
wss://api.valkyrieapp.xyz/ws
wss
ws://localhost:8080/ws
ws
Operations
Pub
/
Accepts one of the following messages:
#1
toggleOnline
Changes the users status to online and broadcasts it to all friends and guilds they are part of.
#2
toggleOffline
Changes the users status to offline and broadcasts it to all friends and guilds they are part of. Leaves all connected rooms.
#3
joinUser
Joins the users room. This room receives guild, DM & friend notifications
#4
joinChannel
Joins the channels room. Checks if the user is a member of said channel. Receives message & typing events.
#5
joinGuild
Joins the guilds room. Requires member access. Receives guild member & channel events.
#6
startTyping
Emits the username to the channel they are typing in.
#7
stopTyping
Removes the username from the channel they were typing in.
#8
getRequestCount
Gets the amount of friend requests the user has.
#9
leaveGuild
Leaves the guild room.
#10
leaveRoom
Leaves the room.
Sub
/
Accepts one of the following messages:
#1
addChannel
A channel was created.
Additional properties are allowed.
#2
deleteChannel
A channel was deleted.
#3
editChannel
A channel was edited
Additional properties are allowed.
#4
editGuild
A guild was edited
Additional properties are allowed.
#5
deleteGuild
A guild was deleted.
#6
addMember
A member got added to the guild.
Additional properties are allowed.
#7
removeMember
A member was removed from the guild.
#8
new_message
A new message was sent to a channel.
Additional properties are allowed.
Additional properties are allowed.
#9
edit_message
A message in this channel was edited.
Additional properties are allowed.
Additional properties are allowed.
#10
delete_message
A message in this channel was deleted.
#11
push_to_top
A notification that pushes the DM to the top of the list.
#12
new_notification
A new message notification, published to all guild members. Additionally sends the channelId to members that currently view the guild.
#13
toggle_online
A notification that the user went online. Gets emited to guild members that currently view the guild and friends of the user.
#14
toggle_offline
A notification that the user went offline. Gets emited to guild members that currently view the guild and friends of the user.
#15
addToTyping
Emits the username to the channel the user is currently typing in.
#16
removeFromTyping
Emits the username to the channel the user was typing in.
#17
send_request
Emits a notification that a friends request was received
#18
add_friend
Adds the added person to the friends list.
Additional properties are allowed.
Additional properties are allowed.
#19
remove_friend
Removes the former friend from the friends list.
#20
requestCount
The amount of friends requests the user has
Messages
#1
toggleOnline
Changes the users status to online and broadcasts it to all friends and guilds they are part of.
#2
toggleOffline
Changes the users status to offline and broadcasts it to all friends and guilds they are part of. Leaves all connected rooms.
#3
joinUser
Joins the users room. This room receives guild, DM & friend notifications
#4
joinChannel
Joins the channels room. Checks if the user is a member of said channel. Receives message & typing events.
#5
joinGuild
Joins the guilds room. Requires member access. Receives guild member & channel events.
#6
startTyping
Emits the username to the channel they are typing in.
#7
stopTyping
Removes the username from the channel they were typing in.
#8
getRequestCount
Gets the amount of friend requests the user has.
#9
leaveGuild
Leaves the guild room.
#10
leaveRoom
Leaves the room.
#11
addChannel
A channel was created.
Additional properties are allowed.
#12
deleteChannel
A channel was deleted.
#13
editChannel
A channel was edited
Additional properties are allowed.
#14
editGuild
A guild was edited
Additional properties are allowed.
#15
deleteGuild
A guild was deleted.
#16
addMember
A member got added to the guild.
Additional properties are allowed.
#17
removeMember
A member was removed from the guild.
#18
new_message
A new message was sent to a channel.
Additional properties are allowed.
Additional properties are allowed.
#19
edit_message
A message in this channel was edited.
Additional properties are allowed.
Additional properties are allowed.
#20
delete_message
A message in this channel was deleted.
#21
push_to_top
A notification that pushes the DM to the top of the list.
#22
new_notification
A new message notification, published to all guild members. Additionally sends the channelId to members that currently view the guild.
#23
toggle_online
A notification that the user went online. Gets emited to guild members that currently view the guild and friends of the user.
#24
toggle_offline
A notification that the user went offline. Gets emited to guild members that currently view the guild and friends of the user.
#25
addToTyping
Emits the username to the channel the user is currently typing in.
#26
removeFromTyping
Emits the username to the channel the user was typing in.
#27
send_request
Emits a notification that a friends request was received
#28
add_friend
Adds the added person to the friends list.
Additional properties are allowed.
Additional properties are allowed.
#29
remove_friend
Removes the former friend from the friends list.
#30
requestCount
The amount of friends requests the user has
================================================
FILE: server/static/js/main.js
================================================
/* eslint-disable */
function bindExpanders() {
var props = document.querySelectorAll('.js-prop');
for (let index = 0; index < props.length; index++) {
const prop = props[index];
prop.addEventListener('click', function (ev) {
ev.stopPropagation();
ev.currentTarget.parentElement.classList.toggle('is-open');
});
}
}
function highlightCode() {
var blocks = document.querySelectorAll('.hljs code');
for (var i = 0; i < blocks.length; i++) {
hljs.highlightBlock(blocks[i]);
}
}
function bindMenuItems() {
var items = document.querySelectorAll('.js-menu-item');
for (var i = 0; i < items.length; i++) {
items[i].addEventListener('click', function () {
document.getElementById("burger-menu").checked = false;
});
}
}
window.addEventListener('load', highlightCode);
window.addEventListener('load', bindExpanders);
window.addEventListener('load', bindMenuItems);
================================================
FILE: server/ws/actions.go
================================================
package ws
// Subscribed Messages
const (
JoinUserAction = "joinUser"
JoinGuildAction = "joinGuild"
JoinChannelAction = "joinChannel"
JoinVoiceAction = "joinVoice"
LeaveGuildAction = "leaveGuild"
LeaveRoomAction = "leaveRoom"
LeaveVoiceAction = "leaveVoice"
StartTypingAction = "startTyping"
StopTypingAction = "stopTyping"
ToggleOnlineAction = "toggleOnline"
ToggleOfflineAction = "toggleOffline"
GetRequestCountAction = "getRequestCount"
)
// Emitted Messages
const (
NewMessageAction = "new_message"
EditMessageAction = "edit_message"
DeleteMessageAction = "delete_message"
AddChannelAction = "add_channel"
AddPrivateChannelAction = "add_private_channel"
EditChannelAction = "edit_channel"
DeleteChannelAction = "delete_channel"
EditGuildAction = "edit_guild"
DeleteGuildAction = "delete_guild"
RemoveFromGuildAction = "remove_from_guild"
AddMemberAction = "add_member"
RemoveMemberAction = "remove_member"
NewDMNotificationAction = "new_dm_notification"
NewNotificationAction = "new_notification"
ToggleOnlineEmission = "toggle_online"
ToggleOfflineEmission = "toggle_offline"
AddToTypingAction = "addToTyping"
RemoveFromTypingAction = "removeFromTyping"
SendRequestAction = "send_request"
AddRequestAction = "add_request"
AddFriendAction = "add_friend"
RemoveFriendAction = "remove_friend"
PushToTopAction = "push_to_top"
RequestCountEmission = "requestCount"
VoiceSignal = "voice-signal"
ToggleMute = "toggle-mute"
ToggleDeafen = "toggle-deafen"
)
================================================
FILE: server/ws/client.go
================================================
package ws
import (
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/sentrionic/valkyrie/model"
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
const (
// Max wait time when writing message to peer
writeWait = 10 * time.Second
// Max time till next pong from peer
pongWait = 60 * time.Second
// Send ping interval, must be less then pong wait time
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer.
maxMessageSize = 10000
)
var newline = []byte{'\n'}
var upgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// Client represents the websockets client at the server
type Client struct {
// The actual websockets connection.
ID string
conn *websocket.Conn
hub *Hub
send chan []byte
rooms map[*Room]bool
}
func newClient(conn *websocket.Conn, hub *Hub, id string) *Client {
return &Client{
ID: id,
conn: conn,
hub: hub,
send: make(chan []byte, 256),
rooms: make(map[*Room]bool),
}
}
func (client *Client) readPump() {
defer func() {
client.disconnect()
}()
client.conn.SetReadLimit(maxMessageSize)
_ = client.conn.SetReadDeadline(time.Now().Add(pongWait))
client.conn.SetPongHandler(func(string) error {
_ = client.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// Start endless read loop, waiting for messages from client
for {
_, jsonMessage, err := client.conn.ReadMessage()
if err != nil {
break
}
client.handleNewMessage(jsonMessage)
}
}
func (client *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
_ = client.conn.Close()
}()
for {
select {
case message, ok := <-client.send:
_ = client.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// The hub closed the channel.
_ = client.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := client.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
_, _ = w.Write(message)
// Attach queued chat messages to the current websockets message.
n := len(client.send)
for i := 0; i < n; i++ {
_, _ = w.Write(newline)
_, _ = w.Write(<-client.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
_ = client.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := client.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func (client *Client) disconnect() {
client.hub.unregister <- client
for room := range client.rooms {
room.unregister <- client
}
close(client.send)
_ = client.conn.Close()
}
// ServeWs handles websockets requests from clients requests.
func ServeWs(hub *Hub, ctx *gin.Context) {
userId := ctx.MustGet("userId").(string)
conn, err := upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
log.Println(err)
return
}
client := newClient(conn, hub, userId)
go client.writePump()
go client.readPump()
hub.register <- client
}
func (client *Client) handleNewMessage(jsonMessage []byte) {
var message model.ReceivedMessage
if err := json.Unmarshal(jsonMessage, &message); err != nil {
log.Printf("Error on unmarshal JSON message %s", err)
}
switch message.Action {
// Join Room Actions
case JoinChannelAction:
client.handleJoinChannelMessage(message)
case JoinGuildAction:
client.handleJoinGuildMessage(message)
case JoinUserAction:
client.handleJoinRoomMessage(message)
case JoinVoiceAction:
client.handleJoinVoiceMessage(message)
// Leave Room Actions
case LeaveRoomAction:
client.handleLeaveRoomMessage(message)
case LeaveGuildAction:
client.handleLeaveGuildMessage(message)
case LeaveVoiceAction:
client.handleLeaveVoiceMessage(message)
// Chat Typing Actions
case StartTypingAction:
client.handleTypingEvent(message, AddToTypingAction)
case StopTypingAction:
client.handleTypingEvent(message, RemoveFromTypingAction)
// Online Status Actions
case ToggleOnlineAction:
client.toggleOnlineStatus(true)
case ToggleOfflineAction:
client.toggleOnlineStatus(false)
// Other
case GetRequestCountAction:
client.handleGetRequestCount()
// Voice Chat
case VoiceSignal:
client.handleVoiceSignal(message)
case ToggleMute:
fallthrough
case ToggleDeafen:
client.updateVCMember(message)
}
}
// handleJoinChannelMessage joins the given room if the user is a member in it
func (client *Client) handleJoinChannelMessage(message model.ReceivedMessage) {
roomName := message.Room
cs := client.hub.channelService
channel, err := cs.Get(roomName)
if err != nil {
return
}
// Check if the user has access to the given channel
if err = cs.IsChannelMember(channel, client.ID); err != nil {
return
}
client.handleJoinRoomMessage(message)
}
// handleJoinGuildMessage joins the given guild if the user is member in it
func (client *Client) handleJoinGuildMessage(message model.ReceivedMessage) {
roomName := message.Room
gs := client.hub.guildService
guild, err := gs.GetGuild(roomName)
if err != nil {
return
}
// Check if the user is member of the given guild
if !isMember(guild, client.ID) {
return
}
client.handleJoinRoomMessage(message)
}
// handleJoinRoomMessage joins the given room
func (client *Client) handleJoinRoomMessage(message model.ReceivedMessage) {
roomName := message.Room
room := client.hub.findRoomById(roomName)
if room == nil {
room = client.hub.createRoom(roomName)
}
client.rooms[room] = true
room.register <- client
}
// handleLeaveGuildMessage leaves the room and updates the members last seen date
func (client *Client) handleLeaveGuildMessage(message model.ReceivedMessage) {
_ = client.hub.guildService.UpdateMemberLastSeen(client.ID, message.Room)
client.handleLeaveRoomMessage(message)
}
// handleLeaveRoomMessage leaves the room
func (client *Client) handleLeaveRoomMessage(message model.ReceivedMessage) {
room := client.hub.findRoomById(message.Room)
delete(client.rooms, room)
if room != nil {
room.unregister <- client
}
}
// handleGetRequestCount returns the users incoming friend request count
func (client *Client) handleGetRequestCount() {
if room := client.hub.findRoomById(client.ID); room != nil {
count, err := client.hub.userService.GetRequestCount(client.ID)
if err != nil {
return
}
msg := model.WebsocketMessage{
Action: RequestCountEmission,
Data: count,
}
room.broadcast <- &msg
}
}
// handleTypingEvent emits the username of the currently typing user to the room
func (client *Client) handleTypingEvent(message model.ReceivedMessage, action string) {
roomID := message.Room
if room := client.hub.findRoomById(roomID); room != nil {
msg := model.WebsocketMessage{
Action: action,
Data: message.Message,
}
room.broadcast <- &msg
}
}
// toggleOnlineStatus updates the users online status and emits it to all
// guilds the user is a member of and all of their friends
func (client *Client) toggleOnlineStatus(isOnline bool) {
uid := client.ID
us := client.hub.userService
user, err := us.Get(uid)
if err != nil {
log.Printf("could not find user: %v", err)
return
}
user.IsOnline = isOnline
if err := us.UpdateAccount(user); err != nil {
log.Printf("could not update user: %v", err)
return
}
ids, err := us.GetFriendAndGuildIds(uid)
if err != nil {
log.Printf("could not find ids: %v", err)
return
}
action := ToggleOfflineEmission
if isOnline {
action = ToggleOnlineEmission
}
for _, id := range *ids {
if room := client.hub.findRoomById(id); room != nil {
msg := model.WebsocketMessage{
Action: action,
Data: uid,
}
room.broadcast <- &msg
}
}
}
// handleJoinGuildMessage joins the given guild's voice chat if the user is a member in it
func (client *Client) handleJoinVoiceMessage(message model.ReceivedMessage) {
roomName := message.Room
room := client.hub.findRoomById(roomName)
if room == nil {
room = client.hub.createRoom(roomName)
}
client.rooms[room] = true
room.register <- client
uid := client.ID
us := client.hub.userService
user, err := us.Get(uid)
if err != nil {
log.Printf("could not find user: %v", err)
return
}
guild, err := client.hub.guildService.GetGuild(room.GetId())
if err != nil {
log.Printf("could not find guild: %v", err)
return
}
if !isMember(guild, user.ID) {
return
}
guild.VCMembers = append(guild.VCMembers, *user)
_ = client.hub.guildService.UpdateGuild(guild)
clients, err := client.hub.guildService.GetVCMembers(guild.ID)
if err != nil {
log.Printf("could not get vc members: %v", err)
return
}
msg := model.WebsocketMessage{
Action: message.Action,
Data: gin.H{
"userId": user.ID,
"clients": clients,
},
}
room.broadcast <- &msg
}
// handleVoiceSignal exchanges the messages needed to setup WebRTC
func (client *Client) handleVoiceSignal(message model.ReceivedMessage) {
data := (*message.Message).(map[string]any)
receiver := data["userId"]
if receiver == "" {
return
}
data["userId"] = client.ID
if room := client.hub.findRoomById(message.Room); room != nil {
for c := range room.clients {
if c.ID == receiver {
msg := model.WebsocketMessage{
Action: message.Action,
Data: data,
}
room.broadcast <- &msg
break
}
}
}
}
// handleLeaveVoiceMessage leaves the voice chat and the room
func (client *Client) handleLeaveVoiceMessage(message model.ReceivedMessage) {
if room := client.hub.findRoomById(message.Room); room != nil {
_ = client.hub.guildService.RemoveVCMember(client.ID, message.Room)
client.handleLeaveRoomMessage(message)
guild, err := client.hub.guildService.GetGuild(room.GetId())
if err != nil {
log.Printf("could not find guild: %v", err)
return
}
clients, err := client.hub.guildService.GetVCMembers(guild.ID)
if err != nil {
log.Printf("could not get vc members: %v", err)
return
}
msg := model.WebsocketMessage{
Action: message.Action,
Data: gin.H{
"userId": client.ID,
"clients": clients,
},
}
room.broadcast <- &msg
}
}
// updateVCMember updates the values of the user in the voice chat
func (client *Client) updateVCMember(message model.ReceivedMessage) {
data := (*message.Message).(map[string]any)
value := data["value"].(bool)
user, err := client.hub.guildService.GetVCMember(client.ID, message.Room)
if err != nil {
log.Printf("could not find vc member: %v", err)
return
}
if message.Action == ToggleMute {
user.IsMuted = value
} else if message.Action == ToggleDeafen {
user.IsDeafened = value
}
err = client.hub.guildService.UpdateVCMember(user.IsMuted, user.IsDeafened, client.ID, message.Room)
if err != nil {
log.Printf("could not update vc member: %v", err)
return
}
if room := client.hub.findRoomById(message.Room); room != nil {
msg := model.WebsocketMessage{
Action: message.Action,
Data: message.Message,
}
room.broadcast <- &msg
}
}
// isMember checks if the user is member of the given guild
func isMember(guild *model.Guild, userId string) bool {
for _, v := range guild.Members {
if v.ID == userId {
return true
}
}
return false
}
================================================
FILE: server/ws/hub.go
================================================
package ws
import (
"github.com/redis/go-redis/v9"
"github.com/sentrionic/valkyrie/model"
)
// Hub contains all rooms and clients
type Hub struct {
clients map[*Client]bool
register chan *Client
unregister chan *Client
broadcast chan []byte
rooms map[*Room]bool
channelService model.ChannelService
guildService model.GuildService
userService model.UserService
redisClient *redis.Client
}
// Config will hold services that will eventually be injected into this
// service layer
type Config struct {
UserService model.UserService
GuildService model.GuildService
ChannelService model.ChannelService
Redis *redis.Client
}
// NewWebsocketHub creates a new Hub
func NewWebsocketHub(c *Config) *Hub {
return &Hub{
clients: make(map[*Client]bool),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan []byte),
rooms: make(map[*Room]bool),
channelService: c.ChannelService,
guildService: c.GuildService,
userService: c.UserService,
redisClient: c.Redis,
}
}
// Run our websocket server, accepting various requests
func (hub *Hub) Run() {
for {
select {
case client := <-hub.register:
hub.registerClient(client)
case client := <-hub.unregister:
hub.unregisterClient(client)
case message := <-hub.broadcast:
hub.broadcastToClients(message)
}
}
}
func (hub *Hub) registerClient(client *Client) {
hub.clients[client] = true
}
func (hub *Hub) unregisterClient(client *Client) {
delete(hub.clients, client)
}
func (hub *Hub) broadcastToClients(message []byte) {
for client := range hub.clients {
client.send <- message
}
}
// BroadcastToRoom sends the given message to all clients connected to the given room
func (hub *Hub) BroadcastToRoom(message []byte, roomId string) {
if room := hub.findRoomById(roomId); room != nil {
room.publishRoomMessage(message)
}
}
func (hub *Hub) findRoomById(id string) *Room {
var foundRoom *Room
for room := range hub.rooms {
if room.GetId() == id {
foundRoom = room
break
}
}
return foundRoom
}
func (hub *Hub) createRoom(id string) *Room {
room := NewRoom(id, hub.redisClient)
go room.RunRoom()
hub.rooms[room] = true
return room
}
================================================
FILE: server/ws/room.go
================================================
package ws
import (
"context"
"github.com/redis/go-redis/v9"
"github.com/sentrionic/valkyrie/model"
"log"
)
// Room represents a websocket room
type Room struct {
id string
clients map[*Client]bool
register chan *Client
unregister chan *Client
broadcast chan *model.WebsocketMessage
redis *redis.Client
}
var ctx = context.Background()
// NewRoom creates a new Room
func NewRoom(id string, rds *redis.Client) *Room {
return &Room{
id: id,
clients: make(map[*Client]bool),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan *model.WebsocketMessage),
redis: rds,
}
}
// RunRoom runs our room, accepting various requests
func (room *Room) RunRoom() {
go room.subscribeToRoomMessages()
for {
select {
case client := <-room.register:
room.registerClientInRoom(client)
case client := <-room.unregister:
room.unregisterClientInRoom(client)
case message := <-room.broadcast:
room.publishRoomMessage(message.Encode())
}
}
}
// registerClientInRoom adds the client to the room
func (room *Room) registerClientInRoom(client *Client) {
room.clients[client] = true
}
// unregisterClientInRoom removes the client from the room
func (room *Room) unregisterClientInRoom(client *Client) {
delete(room.clients, client)
}
// broadcastToClientsInRoom sends the given message to all members in the room
func (room *Room) broadcastToClientsInRoom(message []byte) {
for client := range room.clients {
client.send <- message
}
}
// GetId returns the ID of the room
func (room *Room) GetId() string {
return room.id
}
// publishRoomMessage publishes the message to all clients subscribing to the room
func (room *Room) publishRoomMessage(message []byte) {
err := room.redis.Publish(ctx, room.GetId(), message).Err()
if err != nil {
log.Println(err)
}
}
// subscribeToRoomMessages subscribes to messages in this room
func (room *Room) subscribeToRoomMessages() {
pubsub := room.redis.Subscribe(ctx, room.GetId())
ch := pubsub.Channel()
for msg := range ch {
room.broadcastToClientsInRoom([]byte(msg.Payload))
}
}
================================================
FILE: web/.eslintrc.json
================================================
{
"env": {
"browser": true,
"es2021": true
},
"extends": ["plugin:react/recommended", "airbnb", "airbnb-typescript", "prettier"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["react", "@typescript-eslint", "react-hooks", "prettier"],
"rules": {
"linebreak-style": 0,
"no-undef": "off",
"no-empty": "off",
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": ["off"],
"react/jsx-filename-extension": [
"warn",
{
"extensions": [".tsx"]
}
],
"import/extensions": [
"error",
"ignorePackages",
{
"ts": "never",
"tsx": "never"
}
],
"@typescript-eslint/explicit-function-return-type": [
"error",
{
"allowExpressions": true
}
],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error", // or error
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
"react/jsx-no-useless-fragment": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"react/require-default-props": "off",
"react/prop-types": "off",
"import/prefer-default-export": "off",
"max-len": [
"error",
{
"code": 120
}
],
"react/function-component-definition": [
2,
{
"namedComponents": "arrow-function",
"unnamedComponents": "arrow-function"
}
]
},
"settings": {
"import/resolver": {
"typescript": {}
}
},
"ignorePatterns": ["*.css", "*.html"]
}
================================================
FILE: web/.gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.development
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.eslintcache
/cypress/downloads
================================================
FILE: web/.prettierrc
================================================
{
"printWidth": 120,
"semi": true,
"tabWidth": 2,
"singleQuote": true
}
================================================
FILE: web/cypress/e2e/account.cy.ts
================================================
import { uuid } from '../support/utils';
// Covers the account routes
describe('Account related pages', () => {
const id = uuid();
const email = `${id}@example.com`;
it('redirects an unauthenticated user', () => {
cy.visit('/channels/me');
cy.url().should('include', '/login');
});
it('registers the user', () => {
cy.registerUser(email, id);
});
it('signs in the user', () => {
cy.loginUser(email);
});
it("checks the user's settings page", () => {
cy.loginUser(email);
cy.get('[aria-label=settings]').click();
// Confirm account page got user's info
cy.url().should('include', '/account');
cy.contains('My Account'.toUpperCase());
cy.get('input[name="email"]').should('have.value', email);
cy.get('input[name="username"]').should('have.value', id);
});
it("updates the user's info", () => {
cy.loginUser(email);
cy.get('[aria-label=settings]').click();
// Update the user's info and check for confirmation toast
cy.url().should('include', '/account');
cy.contains('My Account'.toUpperCase());
cy.get('input[name="email"]').should('have.value', email);
cy.get('input[name="username"]').clear().type('Test').should('have.value', 'Test');
cy.get('[type=submit]').click();
cy.wait(200);
cy.contains('Account Updated.');
});
it("updates the user's password", () => {
cy.loginUser(email);
cy.get('[aria-label=settings]').click();
cy.url().should('include', '/account');
cy.contains('Change Password').click();
// Change the user's password
cy.contains('Change your password');
cy.get('input[name="currentPassword"]').type('password').should('have.value', 'password');
cy.get('input[name="newPassword"]').type('password').should('have.value', 'password');
cy.get('input[name="confirmNewPassword"]').type('password').should('have.value', 'password');
cy.contains('Done').click();
cy.wait(200);
cy.contains('Changed Password');
});
it('signs out the user', () => {
cy.loginUser(email);
cy.get('[aria-label=settings]').click();
cy.url().should('include', '/account');
cy.contains('Logout').click();
cy.url().should('include', '');
});
});
================================================
FILE: web/cypress/e2e/channel.cy.ts
================================================
import { uuid } from '../support/utils';
describe('Channels related actions', () => {
// The user's values
const id = uuid().toString();
const email = `${id}@example.com`;
let guildId = '';
let channelId = '';
it('registers the user', () => {
cy.registerUser(email, id);
});
it('creates a guild', () => {
cy.loginUser(email);
cy.intercept({
method: 'POST',
pathname: '/api/guilds/create',
}).as('create');
cy.createGuild(id);
// Confirm the user got sent to the guild and default channel
cy.wait('@create').then((interception) => {
const body = interception.response.body;
const url = `channels/${body.id}/${body.default_channel_id}`;
cy.url().should('include', url);
guildId = body.id;
});
});
it('creates a channel for the guild', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.openGuildMenu();
cy.intercept({
method: 'POST',
pathname: `/api/channels/${guildId}`,
}).as('create');
cy.contains('Create Channel').click();
cy.get('input[name="name"]').type('random').should('have.value', 'random');
cy.get('[type=submit]').click();
// Confirm the user got sent to the newly created channel
cy.wait('@create').then((interception) => {
const body = interception.response.body;
channelId = body.id;
const url = `channels/${guildId}/${channelId}`;
cy.url().should('include', url);
cy.contains('random').should('exist');
});
});
it('should successfully switch between channels', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.contains('Welcome to #general').should('exist');
// Check the other channel is the correct one
cy.get(`a[href^="/channels/${guildId}/"]`).last().click();
cy.contains('Welcome to #random').should('exist');
});
it('should successfully edit the channel', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.contains('random').trigger('mouseover');
cy.get('[aria-label="edit channel"]').click();
cy.intercept({
method: 'PUT',
pathname: `/api/channels/${channelId}`,
}).as('update');
// Edit the values
cy.get('input[name="name"]').clear().type('secret').should('have.value', 'secret');
cy.get('input[type="checkbox"]').check({ force: true });
cy.get('[type=submit]').click();
// Check that the edited channel exists
cy.wait('@update').then((_) => {
cy.contains('secret').should('exist');
});
});
it('should successfully delete the channel', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.get(`a[href^="/channels/${guildId}/"]`).last().click();
cy.contains('secret').last().trigger('mouseover');
cy.get('[aria-label="edit channel"]').last().click();
cy.intercept({
method: 'DELETE',
pathname: `/api/channels/${channelId}`,
}).as('delete');
cy.contains('Delete Channel').click();
cy.get('button').contains('Delete Channel').click();
cy.wait('@delete').then((interception) => {
// Check that the channel is gone and that the user got moved
cy.contains('secret').should('have.length.lte', 1);
cy.contains('Welcome to #general').should('exist');
});
});
});
================================================
FILE: web/cypress/e2e/friend.cy.ts
================================================
import { uuid } from '../support/utils';
describe('Friends related actions', () => {
// Friend values
const friendName = uuid();
const friendEmail = `${friendName}@example.com`;
let friendId = '';
// AuthUser values
const authName = uuid();
const email = `${authName}@example.com`;
it('registers the friend and gets the id', () => {
cy.registerUser(friendEmail, friendName);
// Get the friend's ID and store it
cy.contains('Add Friend').click();
cy.get('input')
.first()
.invoke('val')
.then((text) => {
friendId = text.toString();
});
});
it('signs up the user and gets the id', () => {
cy.registerUser(email, authName);
});
it('sends a friend request to the member', () => {
cy.loginUser(email);
cy.sendRequest(friendId);
// Confirm the 'Pending' tab got an outgoing friend request
cy.contains('Pending').click();
cy.contains(friendName);
cy.contains('Outgoing Friend Request');
});
it('cancels the outgoing request', () => {
cy.loginUser(email);
cy.contains('Pending').click();
cy.contains(friendName);
// Confirm the accept button does not exist
cy.get('[aria-label="accept request"]').should('not.exist');
cy.get('[aria-label="decline request"]').click();
// Confirm the user got removed and is not a friend.
cy.contains(friendName).should('not.exist');
cy.get('button').contains('Friends').click();
cy.contains(friendName).should('not.exist');
});
it('sends another friend request to the member to be checked', () => {
cy.loginUser(email);
cy.sendRequest(friendId);
});
it('checks the incoming friend request and declines it', () => {
cy.loginUser(friendEmail);
// Confirm the user got a request and has the accept button
cy.contains('Pending').click();
cy.contains(authName);
cy.contains('Incoming Friend Request');
cy.get('[aria-label="accept request"]').should('exist');
// Decline request
cy.get('[aria-label="decline request"]').click();
cy.contains(authName).should('not.exist');
// Confirm the user got removed and is not a friend.
cy.get('button').contains('Friends').click();
cy.wait(100);
cy.contains(authName).should('not.exist');
});
it('sends another friend request to the member to be accepted', () => {
cy.loginUser(email);
cy.sendRequest(friendId);
});
it('accepts the friend request', () => {
cy.loginUser(friendEmail);
// Accept request
cy.contains('Pending').click();
cy.contains(authName);
cy.get('[aria-label="accept request"]').click();
cy.contains(authName).should('not.exist');
// Confirm the user is in the 'Friends' tab
cy.get('button').contains('Friends').click();
cy.wait(100);
cy.contains(authName).should('exist');
});
it('directs to the friends dms when clicked', () => {
cy.loginUser(email);
cy.intercept({
method: 'POST',
pathname: `/api/channels/${friendId}/dm`,
}).as('getDM');
// Open the DM
cy.contains(friendName).should('exist').click();
// Confirm it's the friend's DM
cy.contains(friendName).should('exist');
cy.contains(`This is the beginning of your direct message history with @${friendName}`).should('exist');
cy.get('textarea[name="text"]').invoke('attr', 'placeholder').should('contain', `@${friendName}`);
// Confirm the DM's url is the correct one
cy.wait('@getDM').then((interception) => {
const body = interception.response.body;
const url = `/channels/me/${body.id}`;
cy.url().should('include', url);
});
});
it('should successfully go to the DM when clicked on the item', () => {
cy.loginUser(email);
cy.get('ul[id="dm-list"]').children().contains(friendName).click();
// Confirm it's the friend's DM
cy.contains(friendName).should('exist');
cy.contains(`This is the beginning of your direct message history with @${friendName}`).should('exist');
cy.get('textarea[name="text"]').invoke('attr', 'placeholder').should('contain', `@${friendName}`);
// Check that messaging is possible and the message gets added to the chat
cy.get('textarea[name="text"]').type('Hello World{enter}').should('have.value', '');
cy.wait(50);
cy.contains('Hello World');
});
it('closes the dm when the close button is pressed', () => {
cy.loginUser(email);
// Check that the DM exists
cy.get('ul[id="dm-list"]').children().contains(friendName).should('exist');
cy.get('ul[id="dm-list"] li:first').trigger('mouseover');
// Close the DM and confirm it's gone
cy.get('[aria-label="close dm"]').click();
cy.get('ul[id="dm-list"]').children().contains(friendName).should('not.exist');
});
it('removes the friend', () => {
cy.loginUser(email);
// Check that the friend exists in the tab
cy.get('ul[id="friend-list"]').children().contains(friendName).should('exist');
// Confirm and remove friend
cy.get('[aria-label="remove friend"]').click();
cy.contains('Remove Friend').click();
// Confirm the friend got removed
cy.get('ul[id="friend-list"]').should('not.exist');
});
});
================================================
FILE: web/cypress/e2e/guild.cy.ts
================================================
import { uuid } from '../support/utils';
describe('Guild related actions', () => {
// The user's values
const id = uuid().toString();
const email = `${id}@example.com`;
// The mock members values
const memberId = uuid().toString();
const memberEmail = `${memberId}@example.com`;
// The invite link and guildId
let invite = '';
let guildId = '';
it('registers the user', () => {
cy.registerUser(email, id);
});
it('creates a guild', () => {
cy.loginUser(email);
cy.intercept({
method: 'POST',
pathname: '/api/guilds/create',
}).as('create');
cy.createGuild(id);
// Confirm the user got sent to the guild and default channel
cy.wait('@create').then((interception) => {
const body = interception.response.body;
const url = `channels/${body.id}/${body.default_channel_id}`;
cy.url().should('include', url);
guildId = body.id;
});
});
it('should update the server after it got edited', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.openGuildMenu();
cy.contains('Server Settings').click();
cy.intercept({
method: 'PUT',
pathname: `/api/guilds/${guildId}`,
}).as('update');
// Updates the values and saves them
cy.get('input[name="name"]').clear().type('Valkyrie').should('have.value', 'Valkyrie');
cy.contains('Save Changes').click();
// Check the updates were applied
cy.wait('@update').then((_) => {
cy.contains('Valkyrie').should('exist');
});
});
it('should successfully clear the invites', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.openGuildMenu();
cy.contains('Server Settings').click();
cy.intercept({
method: 'DELETE',
pathname: `/api/guilds/${guildId}/invite`,
}).as('clear');
cy.contains('Invalidate Links').click();
// Check the invites succesfully got cleared
cy.wait('@clear').then((interception) => {
const body = interception.response.body;
expect(body).eq(true);
});
});
it('should delete the server and go to home', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// Delete server and confirm the request
cy.openGuildMenu();
cy.contains('Server Settings').click();
cy.contains('Delete Server').click();
cy.contains('Delete Server').click();
// Confirm the user is back at home and the guild is gone
// Not working due to websocket connection problems
// cy.url().should('include', '/channels/me');
// cy.get('a[href*="channels"]').not('a[href*="channels/me"]').should('have.length', 0);
});
it('creates another guild to get an invite', () => {
cy.loginUser(email);
cy.intercept({
method: 'POST',
pathname: '/api/guilds/create',
}).as('create');
cy.createGuild(id);
// Confirm the user got sent to the guild and default channel
cy.wait('@create').then((interception) => {
const body = interception.response.body;
const url = `channels/${body.id}/${body.default_channel_id}`;
cy.url().should('include', url);
guildId = body.id;
});
// get the invite for the other user
cy.openGuildMenu();
cy.contains('Invite People').click();
// Check unlimited invites
cy.get('input[type="checkbox"]').check({ force: true });
cy.wait(50);
// Store the invite in the variable
cy.get('input[id="invite-link"]')
.first()
.invoke('val')
.then((text) => {
invite = text.toString();
});
});
it('joins the guild for the given link', () => {
cy.registerUser(memberEmail, memberId);
cy.intercept({
method: 'POST',
pathname: '/api/guilds/join',
}).as('join');
cy.joinGuild(invite);
// Confirm the user got sent to the guild and default channel
cy.wait('@join').then((interception) => {
const body = interception.response.body;
const url = `channels/${body.id}/${body.default_channel_id}`;
cy.url().should('include', url);
});
cy.contains(`${id}'s server`).should('exist');
cy.contains(`Welcome to #general`).should('exist');
});
it('should not show "Server Settings" and "Create Channel" to the non owner', () => {
cy.loginUser(memberEmail);
cy.clickOnFirstGuild();
cy.openGuildMenu();
cy.contains('Server Settings').should('not.exist');
cy.contains('Create Channel').should('not.exist');
});
it('should leave the server', () => {
cy.loginUser(memberEmail);
cy.clickOnFirstGuild();
cy.openGuildMenu();
cy.contains('Leave Server').click();
// Confirm the user is back at home and the guild is gone
cy.url().should('include', '/channels/me');
cy.get('a[href*="channels"]').not('a[href*="channels/me"]').should('have.length', 0);
});
it('creates a third guild to test switching', () => {
cy.loginUser(email);
cy.createGuild('Test');
});
it('successfully switches between the guilds', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.url().should('include', guildId);
cy.contains(`${id}'s server`).should('exist');
// Click on the second server and confirm it's the second one created
cy.get('a[href*="channels"]').eq(2).click();
cy.contains(`Test's server`).should('exist');
});
});
================================================
FILE: web/cypress/e2e/member.cy.ts
================================================
import { uuid } from '../support/utils';
describe('Members related actions', () => {
// Member values
const memberName = uuid();
const memberEmail = `${memberName}@example.com`;
let invite = '';
// AuthUser values
const authName = uuid();
const email = `${authName}@example.com`;
it('registers the user', () => {
cy.registerUser(email, authName);
});
it('creates a guild and get the invite', () => {
cy.loginUser(email);
cy.createGuild(authName);
// get the invite for the other user
cy.openGuildMenu();
cy.contains('Invite People').click();
// Check unlimited invites
cy.get('input[type="checkbox"]').check({ force: true });
cy.wait(50);
// Store the invite in the variable
cy.get('input[id="invite-link"]')
.first()
.invoke('val')
.then((text) => {
invite = text.toString();
});
});
it('successfully changes the members settings', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.openGuildMenu();
cy.contains('Change Appearance').click();
// Sets the member values
cy.get('input[name="nickname"]').clear().type('Tester').should('have.value', 'Tester');
cy.get('div[title="#0693E3"]').click();
cy.wait(100);
cy.contains('Save').click();
});
it('joins the guild for the given link', () => {
cy.registerUser(memberEmail, memberName);
cy.joinGuild(invite);
// Confirm the above changes
cy.wait(100);
cy.contains('Tester').should('exist');
// Check that the two members + two labels are there
cy.get('ul[id="member-list"]').children().should('have.length', 4);
});
it('should not show "Kick" and "Ban" options to the non owner', () => {
cy.loginUser(memberEmail);
cy.clickOnFirstGuild();
cy.get('ul[id="member-list"]').contains('Tester').rightclick();
cy.contains('Ban').should('not.exist');
cy.contains('Kick').should('not.exist');
});
it('successfully resets the members settings', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.openGuildMenu();
cy.contains('Change Appearance').click();
// Reset the values
cy.contains('Reset Nickname').click();
cy.contains('Reset Color').click();
cy.wait(100);
cy.contains('Save').click();
});
it('should go to the members DMs on click', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.contains(memberName).rightclick();
cy.contains('Message').click();
// Confirm the user got moved to the correct DM
cy.contains(memberName).should('exist');
cy.contains(`This is the beginning of your direct message history with @${memberName}`).should('exist');
cy.get('textarea[name="text"]').invoke('attr', 'placeholder').should('contain', `@${memberName}`);
});
it('should successfully sent a friends request', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.contains(memberName).rightclick();
cy.contains('Add Friend').click();
// Go to the pending tab to confirm the request
cy.get('a[href="/channels/me"]').click();
cy.contains('Pending').click();
cy.contains(memberName).should('exist');
cy.contains('Outgoing Friend Request').should('exist');
});
it('should kick and remove the member', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// Kick the user
cy.contains(memberName).rightclick();
cy.contains(`Kick ${memberName}`).click();
cy.get('button').contains('Kick').click();
// Confirm the member is gone
cy.get('ul[id="member-list"]').children().should('have.length', 3);
cy.contains(memberName).should('not.exist');
});
it('should successfully rejoin the server', () => {
cy.loginUser(memberEmail);
cy.joinGuild(invite);
});
it('should ban and remove the member', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// Ban the user
cy.contains(memberName).rightclick();
cy.contains(`Ban ${memberName}`).click();
cy.get('button').contains('Ban').click();
// Confirm the member is gone
cy.get('ul[id="member-list"]').children().should('have.length', 3);
cy.contains(memberName).should('not.exist');
});
it('should not be able to rejoin the server', () => {
cy.loginUser(memberEmail);
cy.joinGuild(invite);
// Confirm the user did not join the guild
cy.contains('You are banned from this server').should('exist');
cy.url().should('include', '/channels/me');
});
it('should contain the member in the ban list', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// Go to the ban list modal
cy.openGuildMenu();
cy.contains('Server Settings').click();
cy.contains('Bans').click();
// Check the member exists there
cy.wait(100);
cy.contains(memberName).should('exist');
cy.get('button[aria-label="unban user"]').click();
// Confirm the member got unbanned
cy.contains(memberName).should('not.exist');
});
it('should not display a context menu when clicking on oneself', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.contains(authName).rightclick();
cy.contains('Add Friend').should('not.exist');
});
it('should toggle the member list on click', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// Toggle the member list
cy.get('ul[id="member-list"]').should('be.visible');
cy.get('[aria-label="toggle member list"]').click();
cy.get('ul[id="member-list"]').should('not.exist');
cy.get('[aria-label="toggle member list"]').click();
cy.get('ul[id="member-list"]').should('be.visible');
});
});
================================================
FILE: web/cypress/e2e/message.cy.ts
================================================
import { uuid } from '../support/utils';
const waitTime = 1000;
describe('Message related actions', () => {
// The user's values
const id = uuid().toString();
const email = `${id}@example.com`;
// The mock members values
const memberName = uuid().toString();
const memberEmail = `${memberName}@example.com`;
let userId = '';
// The invite link and guildId
let invite = '';
it('should register the user and create a guild', () => {
cy.intercept({
method: 'POST',
pathname: '/api/account/register',
}).as('register');
cy.registerUser(email, id);
cy.wait('@register').then((interception) => {
const body = interception.response.body;
userId = body.id;
});
cy.createGuild(id);
});
it('should successfully post a message', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// Send message
cy.get('textarea[name="text"]').type('Hello, World{enter}').should('have.value', '');
// Confirm it got added to the chat
// cy.wait(100);
// cy.getChat().should('exist');
// cy.getChat().children().should('have.length', 1);
// cy.firstMessage().contains('Hello, World');
});
it('should confirm that the message got sent', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.wait(waitTime);
cy.getChat().should('exist');
cy.getChat().children().should('have.length', 1);
cy.firstMessage().contains('Hello, World');
});
it('should post another message', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.get('textarea[name="text"]').type('Hello, Server{enter}').should('have.value', '');
});
it('should display newer messages under older', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// Confirm it got added under the first one
cy.wait(waitTime);
cy.getChat().children().should('have.length', 2);
cy.firstMessage().contains('Hello, Server');
cy.getChat().children().last().contains('Hello, World');
});
it('should successfully delete the message', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.firstMessage().rightclick();
cy.contains('Delete Message').click();
cy.get('button').contains('Delete').click();
});
// Manually check the message is gone because of websocket problems
it('should confirm that the message got deleted', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.wait(waitTime);
cy.getChat().children().should('have.length', 1);
cy.getChat().children().contains('Hello, Server').should('not.exist');
});
it('should successfully edit the message', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.firstMessage().rightclick();
cy.contains('Edit Message').click();
cy.wait(waitTime);
cy.get('input[id="editMessage"]').clear().type('Hello, Update');
cy.get('button').contains('Save').click();
// // Confirm it got edited and got the edit span
// cy.firstMessage().contains('Hello, Update');
// cy.firstMessage().contains('(edited)');
});
it('should confirm the message got edited', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// Confirm it got edited and got the edit span
cy.firstMessage().contains('Hello, Update');
cy.firstMessage().contains('(edited)');
});
it('should not display "Add Friend" or "Message" for own avatar', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
cy.wait(waitTime);
cy.firstMessage().get('img').eq(1).rightclick();
cy.contains('Add Friend').should('not.exist');
cy.contains('Message').should('not.exist');
});
it('should get an invite for the other member', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// get the invite for the other user
cy.openGuildMenu();
cy.contains('Invite People').click();
// Check unlimited invites
cy.get('input[type="checkbox"]').check({ force: true });
cy.wait(waitTime);
// Store the invite in the variable
cy.get('input[id="invite-link"]')
.first()
.invoke('val')
.then((text) => {
invite = text.toString();
});
});
it('should register the other member and join the guild', () => {
cy.registerUser(memberEmail, memberName);
cy.joinGuild(invite);
});
it('should not display message options for the member context menu when clicking on the owners message', () => {
cy.loginUser(memberEmail);
cy.clickOnFirstGuild();
cy.getChat().children().last().rightclick();
cy.contains('Edit Message').should('not.exist');
cy.contains('Delete Message').should('not.exist');
});
it('should successfully add the user', () => {
cy.loginUser(memberEmail);
cy.clickOnFirstGuild();
cy.intercept({
method: 'POST',
pathname: `/api/account/${userId}/friend`,
}).as('addFriend');
cy.wait(waitTime);
cy.firstMessage().get('img').eq(1).rightclick();
cy.contains('Add Friend').click();
cy.wait('@addFriend').then((_) => {
// Go to the pending tab to confirm the request
cy.get('a[href="/channels/me"]').click();
cy.contains('Pending').click();
cy.contains(memberName).should('exist');
cy.contains('Outgoing Friend Request').should('exist');
});
});
it('should successfully go to the DMs with the user', () => {
cy.loginUser(memberEmail);
cy.clickOnFirstGuild();
cy.intercept({
method: 'POST',
pathname: `/api/channels/${userId}/dm`,
}).as('create');
cy.wait(waitTime);
cy.firstMessage().get('img').eq(1).rightclick();
cy.contains('Message').click();
cy.wait('@create').then(() => {
// Confirm the user got moved to the correct DM
cy.contains(id).should('exist');
cy.contains(`This is the beginning of your direct message history with @${id}`).should('exist');
cy.get('textarea[name="text"]').invoke('attr', 'placeholder').should('contain', `@${id}`);
});
});
it('should successfully post a message', () => {
cy.loginUser(memberEmail);
cy.clickOnFirstGuild();
cy.get('textarea[name="text"]').type('Hello, Owner{enter}').should('have.value', '');
});
it("should be able to delete the member's message as the owner", () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// Owner can delete the other person's message, but cannot edit it
cy.firstMessage().rightclick();
cy.contains('Edit Message').should('not.exist');
cy.contains('Delete Message').click();
cy.get('button').contains('Delete').click();
});
it('should confirm that the message got deleted', () => {
cy.loginUser(email);
cy.clickOnFirstGuild();
// Confirm the message got deleted
cy.wait(100);
cy.getChat().children().should('have.length', 1);
cy.getChat().children().contains('Hello, Owner').should('not.exist');
});
});
================================================
FILE: web/cypress/fixtures/example.json
================================================
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}
================================================
FILE: web/cypress/plugins/index.js
================================================
///
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
};
================================================
FILE: web/cypress/support/commands.ts
================================================
Cypress.Commands.add('registerUser', (email, id) => {
cy.visit('/');
cy.contains('Valkyrie');
cy.contains('Register').click();
cy.url().should('include', '/register');
cy.get('input[name="email"]').type(email).should('have.value', email);
cy.get('input[name="username"]').type(id).should('have.value', id);
cy.get('input[name="password"]').type('password').should('have.value', 'password');
cy.contains('Register').click();
cy.url().should('include', '/channels/me');
});
Cypress.Commands.add('loginUser', (email) => {
cy.visit('/');
cy.contains('Valkyrie');
cy.contains('Login').click();
cy.url().should('include', '/login');
cy.get('input[name="email"]').type(email).should('have.value', email);
cy.get('input[name="password"]').type('password').should('have.value', 'password');
cy.contains('Login').click();
cy.url().should('include', '/channels/me');
});
Cypress.Commands.add('sendRequest', (id) => {
cy.contains('Add Friend').click();
cy.get('input[name=id]').type(id).should('have.value', id);
cy.get('[type=submit]').click();
});
Cypress.Commands.add('createGuild', (id) => {
cy.get('[id="add-guild-icon"]').click();
cy.contains('Create My Own').click();
cy.get('input').clear().type(`${id}'s server`);
cy.get('[type=submit]').click();
cy.contains(`${id}'s server`).should('exist');
cy.contains(`Welcome to #general`).should('exist');
});
Cypress.Commands.add('clickOnFirstGuild', () => {
cy.wait(1000);
cy.get('a[href*="channels"]').not('a[href*="channels/me"]').first().click();
});
Cypress.Commands.add('openGuildMenu', () => {
cy.get('[id^="menu-button-"]').click();
});
Cypress.Commands.add('joinGuild', (invite) => {
cy.get('[id="add-guild-icon"]').click();
cy.contains('Join a Server').click();
cy.get('input').type(invite).should('have.value', invite);
cy.get('[type=submit]').click();
cy.wait(1000);
});
Cypress.Commands.add('getChat', () => {
cy.get('.infinite-scroll-component');
});
Cypress.Commands.add('firstMessage', () => {
cy.getChat().children().first();
});
================================================
FILE: web/cypress/support/e2e.js
================================================
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands';
// Alternatively you can use CommonJS syntax:
// require('./commands')
================================================
FILE: web/cypress/support/index.d.ts
================================================
// load type definitions that come with Cypress module
// eslint-disable-next-line spaced-comment
///
declare namespace Cypress {
interface Chainable {
registerUser(email: string, id: string): Chainable;
loginUser(email: string): Chainable;
sendRequest(id: string): Chainable;
createGuild(id: string): Chainable;
joinGuild(invite: string): Chainable;
clickOnFirstGuild(): Chainable;
openGuildMenu(): Chainable;
getChat(): Chainable;
firstMessage(): Chainable;
}
}
================================================
FILE: web/cypress/support/utils.ts
================================================
export const uuid = (): string => Cypress._.random(0, 1e6).toString();
================================================
FILE: web/cypress/tsconfig.json
================================================
{
"compilerOptions": {
"target": "es5",
"lib": ["es5", "dom"],
"types": ["cypress"]
},
"include": ["**/*.ts", "support/index.js"]
}
================================================
FILE: web/cypress.config.ts
================================================
import { defineConfig } from 'cypress';
export default defineConfig({
video: false,
screenshotOnRunFailure: false,
retries: 2,
e2e: {
baseUrl: 'http://localhost:3000',
},
defaultCommandTimeout: 8000,
});
================================================
FILE: web/package.json
================================================
{
"name": "valkyrie",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "npx eslint src/**",
"prettier": "npx prettier --write **/*.{ts,js,css,html,tsx}",
"compile": "tsc",
"workflow": "yarn compile && yarn prettier && yarn lint && yarn test --watchAll=false",
"cypress": "npx cypress open"
},
"dependencies": {
"@chakra-ui/react": "^2.7.1",
"@chakra-ui/theme-tools": "^2.0.18",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@tanstack/react-query": "^4.29.15",
"@testing-library/jest-dom": "^5.16.5",
"axios": "^1.4.0",
"chakra-ui-autocomplete": "^1.4.5",
"dayjs": "^1.11.8",
"formik": "^2.4.2",
"framer-motion": "^10.12.17",
"msw": "^1.2.2",
"react": "^18.2.0",
"react-color": "^2.19.3",
"react-contexify": "^5.0.0",
"react-dom": "^18.2.0",
"react-easy-crop": "^4.7.5",
"react-icons": "4.10.1",
"react-infinite-scroll-component": "^6.1.0",
"react-router-dom": "^6.14.0",
"react-scripts": "5.0.1",
"react-textarea-autosize": "^8.5.0",
"reconnecting-websocket": "^4.4.0",
"yup": "^1.2.0",
"zustand": "^4.3.8"
},
"devDependencies": {
"@testing-library/dom": "^9.3.1",
"@testing-library/react": "^14.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.4.3",
"@types/node": "^20.3.1",
"@types/react": "^18.2.14",
"@types/react-color": "^3.0.6",
"@types/react-dom": "^18.2.6",
"@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^5.60.0",
"@typescript-eslint/parser": "^5.60.0",
"cypress": "12.15.0",
"eslint": "^8.43.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.8.8",
"react-test-renderer": "^18.2.0",
"typescript": "^5.1.3"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"jest": {
"transform": {
"^.+\\.[t|j]sx?$": "babel-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!axios)/"
]
}
}
================================================
FILE: web/public/_redirects
================================================
/* /index.html 200
================================================
FILE: web/public/index.html
================================================
Valkyrie
You need to enable JavaScript to run this app.
================================================
FILE: web/public/manifest.json
================================================
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
================================================
FILE: web/public/robots.txt
================================================
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
================================================
FILE: web/src/App.tsx
================================================
import * as React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppRoutes } from './routes/Routes';
import { GlobalState } from './components/sections/GlobalState';
const client = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: Infinity,
cacheTime: 0,
},
},
});
export const App: React.FC = () => (
);
================================================
FILE: web/src/components/common/GuildPills.tsx
================================================
import { Box } from '@chakra-ui/react';
import React from 'react';
export const NotificationIndicator: React.FC = () => (
);
export const ChannelNotificationIndicator: React.FC = () => (
);
export const ActiveGuildPill: React.FC = () => (
);
export const HoverGuildPill: React.FC = () => (
);
================================================
FILE: web/src/components/common/InputField.tsx
================================================
/* eslint-disable react/jsx-props-no-spreading */
import React, { InputHTMLAttributes } from 'react';
import { useField } from 'formik';
import { FormControl, FormErrorMessage, FormLabel, Input, Text } from '@chakra-ui/react';
type InputFieldProps = InputHTMLAttributes & {
label: string;
name: string;
};
export const InputField: React.FC = ({ label, ...props }) => {
const [field, { error, touched }] = useField(props);
return (
{label}
{/* @ts-ignore */}
{error}
);
};
================================================
FILE: web/src/components/common/Logo.tsx
================================================
import { Box, Image, Text } from '@chakra-ui/react';
import React from 'react';
export const Logo: React.FC = () => (
);
================================================
FILE: web/src/components/common/NotificationIcon.tsx
================================================
import { Flex, Text } from '@chakra-ui/react';
import React from 'react';
interface NotificationIconProps {
count: number;
}
export const NotificationIcon: React.FC = ({ count }) => (
{count}
);
export const PingIcon: React.FC = ({ count }) => (
{count}
);
================================================
FILE: web/src/components/items/ChannelListItem.tsx
================================================
import React, { useEffect, useState } from 'react';
import { Flex, Icon, ListItem, Text, useDisclosure } from '@chakra-ui/react';
import { FaHashtag, FaUserLock } from 'react-icons/fa';
import { MdSettings } from 'react-icons/md';
import { Link, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { userStore } from '../../lib/stores/userStore';
import { ChannelSettingsModal } from '../modals/ChannelSettingsModal';
import { useGetCurrentGuild } from '../../lib/utils/hooks/useGetCurrentGuild';
import { ChannelNotificationIndicator } from '../common/GuildPills';
import { cKey } from '../../lib/utils/querykeys';
import { Channel } from '../../lib/models/channel';
interface ChannelListItemProps {
channel: Channel;
guildId: string;
}
export const ChannelListItem: React.FC = ({ channel, guildId }) => {
const currentPath = `/channels/${guildId}/${channel.id}`;
const location = useLocation();
const isActive = location.pathname === currentPath;
const [showSettings, setShowSettings] = useState(false);
const current = userStore((state) => state.current);
const guild = useGetCurrentGuild(guildId);
const { isOpen, onOpen, onClose } = useDisclosure();
const cache = useQueryClient();
useEffect(() => {
if (channel.hasNotification && isActive) {
cache.setQueryData([cKey, guildId], (d) => {
if (!d) return [];
return d.map((c) => (c.id === channel.id ? { ...c, hasNotification: false } : c));
});
}
});
return (
setShowSettings(false)}
onMouseEnter={() => setShowSettings(true)}
>
{channel.hasNotification && }
{channel.name}
{current?.id === guild?.ownerId && (showSettings || isOpen) && (
<>
{
e.preventDefault();
onOpen();
}}
/>
{isOpen && (
)}
>
)}
);
};
================================================
FILE: web/src/components/items/DMListItem.tsx
================================================
import React, { useState } from 'react';
import { Avatar, AvatarBadge, Flex, Icon, ListItem, Text } from '@chakra-ui/react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { IoMdClose } from 'react-icons/io';
import { useQueryClient } from '@tanstack/react-query';
import { closeDirectMessage } from '../../lib/api/handler/dm';
import { dmKey } from '../../lib/utils/querykeys';
import { DMChannel } from '../../lib/models/dm';
interface DMListItemProps {
dm: DMChannel;
}
export const DMListItem: React.FC = ({ dm }) => {
const currentPath = `/channels/me/${dm.id}`;
const location = useLocation();
const isActive = location.pathname === currentPath;
const [showCloseButton, setShowButton] = useState(false);
const navigate = useNavigate();
const cache = useQueryClient();
const closeDM = async (): Promise => {
try {
await closeDirectMessage(dm.id);
cache.setQueryData([dmKey], (d) => d?.filter((c) => c.id !== dm.id) ?? []);
if (isActive) {
navigate('/channels/me', { replace: true });
}
} catch (err) {}
};
return (
setShowButton(false)}
onMouseEnter={() => setShowButton(true)}
>
{dm.user.username}
{showCloseButton && (
{
e.preventDefault();
await closeDM();
}}
/>
)}
);
};
================================================
FILE: web/src/components/items/FriendsListItem.tsx
================================================
import { Avatar, AvatarBadge, Flex, IconButton, ListItem, Text, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import { FaEllipsisV } from 'react-icons/fa';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { getOrCreateDirectMessage } from '../../lib/api/handler/dm';
import { RemoveFriendModal } from '../modals/RemoveFriendModal';
import { dmKey } from '../../lib/utils/querykeys';
import { Friend } from '../../lib/models/friend';
import { DMChannel } from '../../lib/models/dm';
interface FriendsListItemProp {
friend: Friend;
}
export const FriendsListItem: React.FC = ({ friend }) => {
const navigate = useNavigate();
const { isOpen, onOpen, onClose } = useDisclosure();
const cache = useQueryClient();
const getDMChannel = async (): Promise => {
try {
const { data } = await getOrCreateDirectMessage(friend.id);
if (data) {
cache.setQueryData([dmKey], (d) => {
const queryData = d ?? [];
const index = queryData.findIndex((dm) => dm.id === data.id);
if (index === -1) return [data, ...queryData];
return queryData;
});
navigate(`/channels/me/${data.id}`);
}
} catch (err) {}
};
return (
{friend.username}
}
borderRadius="50%"
aria-label="remove friend"
onClick={(e) => {
e.preventDefault();
onOpen();
}}
/>
{isOpen && }
);
};
================================================
FILE: web/src/components/items/GuildListItem.tsx
================================================
import React, { useEffect, useState } from 'react';
import { Avatar, Flex } from '@chakra-ui/react';
import { Link, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { StyledTooltip } from '../sections/StyledTooltip';
import { ActiveGuildPill, HoverGuildPill, NotificationIndicator } from '../common/GuildPills';
import { gKey } from '../../lib/utils/querykeys';
import { Guild } from '../../lib/models/guild';
interface GuildListItemProps {
guild: Guild;
}
export const GuildListItem: React.FC = ({ guild }) => {
const location = useLocation();
const isActive = location.pathname.includes(guild.id);
const [isHover, setHover] = useState(false);
const cache = useQueryClient();
useEffect(() => {
if (guild.hasNotification && isActive) {
cache.setQueryData([gKey], (data) => {
if (!data) return [];
return data.map((g) => (g.id === guild.id ? { ...g, hasNotification: false } : g));
});
}
});
return (
{isActive && }
{isHover && }
{guild.hasNotification && }
{guild.icon ? (
setHover(false)}
onMouseEnter={() => setHover(true)}
/>
) : (
setHover(false)}
onMouseEnter={() => setHover(true)}
>
{guild.name[0]}
)}
);
};
================================================
FILE: web/src/components/items/MemberListItem.tsx
================================================
import React from 'react';
import { Avatar, AvatarBadge, Flex, ListItem, Text } from '@chakra-ui/react';
import { useContextMenu } from 'react-contexify';
import { useParams } from 'react-router-dom';
import { useGetCurrentGuild } from '../../lib/utils/hooks/useGetCurrentGuild';
import { userStore } from '../../lib/stores/userStore';
import { MemberContextMenu } from '../menus/MemberContextMenu';
import { RouterProps } from '../../lib/models/routerProps';
import { Member } from '../../lib/models/member';
interface MemberListItemProps {
member: Member;
}
export const MemberListItem: React.FC = ({ member }) => {
const current = userStore((state) => state.current);
const { guildId } = useParams() as RouterProps;
const guild = useGetCurrentGuild(guildId);
const isOwner = guild !== undefined && guild.ownerId === current?.id;
const { show } = useContextMenu({
id: member.id,
});
return (
<>
{member.nickname ?? member.username}
{member.id !== current?.id && }
>
);
};
================================================
FILE: web/src/components/items/NotificationListItem.tsx
================================================
import React, { useEffect, useState } from 'react';
import { Avatar, Flex } from '@chakra-ui/react';
import { Link, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { StyledTooltip } from '../sections/StyledTooltip';
import { ActiveGuildPill, HoverGuildPill, NotificationIndicator } from '../common/GuildPills';
import { NotificationIcon } from '../common/NotificationIcon';
import { dmKey, nKey } from '../../lib/utils/querykeys';
import { DMChannel, DMNotification } from '../../lib/models/dm';
interface NotificationListItemProps {
notification: DMNotification;
}
export const NotificationListItem: React.FC = ({ notification }) => {
const location = useLocation();
const isActive = location.pathname.includes(notification.id);
const [isHover, setHover] = useState(false);
const cache = useQueryClient();
useEffect(() => {
if (isActive) {
cache.setQueryData([nKey], (d) => d?.filter((c) => c.id !== notification.id) ?? []);
}
});
const handleClick = (): void => {
if (window.location.pathname.includes('/channels/me')) {
const newChannel: DMChannel = {
id: notification.id,
user: notification.user,
};
cache.setQueryData([dmKey], (d) => {
const data = d ?? [];
const index = d!.findIndex((dm) => dm.id === notification.id);
if (index === -1) return [newChannel, ...data];
return data;
});
}
};
return (
{isActive && }
{isHover && }
setHover(false)}
onMouseEnter={() => setHover(true)}
onClick={() => handleClick()}
>
);
};
================================================
FILE: web/src/components/items/RequestListItem.tsx
================================================
import { Avatar, Box, Flex, IconButton, ListItem, Text } from '@chakra-ui/react';
import React from 'react';
import { BiCheck } from 'react-icons/bi';
import { AiOutlineClose } from 'react-icons/ai';
import { useQueryClient } from '@tanstack/react-query';
import { StyledTooltip } from '../sections/StyledTooltip';
import { acceptFriendRequest, declineFriendRequest } from '../../lib/api/handler/account';
import { fKey, rKey } from '../../lib/utils/querykeys';
import { FriendRequest, RequestType } from '../../lib/models/friend';
interface RequestListItemProps {
request: FriendRequest;
}
export const RequestListItem: React.FC = ({ request }) => {
const cache = useQueryClient();
const acceptRequest = async (): Promise => {
try {
const { data } = await acceptFriendRequest(request.id);
if (data) {
cache.setQueryData([rKey], (d) => d?.filter((r) => r.id !== request.id) ?? []);
await cache.invalidateQueries([fKey]);
}
} catch (err) {}
};
const declineRequest = async (): Promise => {
try {
const { data } = await declineFriendRequest(request.id);
if (data) {
cache.setQueryData([rKey], (d) => d?.filter((r) => r.id !== request.id) ?? []);
}
} catch (err) {}
};
return (
{request.username}
{request.type === RequestType.INCOMING ? 'Incoming Friend Request' : 'Outgoing Friend Request'}
{request.type === 1 && (
}
borderRadius="50%"
aria-label="accept request"
fontSize="28px"
onClick={acceptRequest}
mr="2"
/>
)}
}
borderRadius="50%"
aria-label="decline request"
fontSize="20px"
onClick={declineRequest}
/>
);
};
================================================
FILE: web/src/components/items/VoiceChannelItem.tsx
================================================
import { Avatar, Flex, Icon, Text } from '@chakra-ui/react';
import React, { useCallback } from 'react';
import { MdHeadsetOff, MdMicOff } from 'react-icons/md';
interface VoiceUserVisualProps {
username: string;
image: string;
isMuted: boolean;
isDeafened: boolean;
}
const VoiceUserVisual: React.FC = ({ username, image, isMuted, isDeafened }) => (
{username}
{isDeafened && }
{isMuted && }
);
interface VoiceUserProps extends VoiceUserVisualProps {
stream?: MediaStream | null;
muted?: boolean;
controls?: boolean;
}
export const VoiceChannelItem: React.FC = ({
username,
image,
stream,
muted = false,
controls = false,
isMuted,
isDeafened,
}) => {
const refAudio = useCallback(
(node: HTMLAudioElement) => {
if (node && stream) {
const audio = node;
audio.srcObject = stream;
}
},
[stream]
);
if (!stream) return ;
return (
<>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
>
);
};
================================================
FILE: web/src/components/items/css/ContextMenu.css
================================================
.react-contexify {
position: fixed;
opacity: 0;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-color: #18191c;
box-sizing: border-box;
box-shadow: 0px 10px 30px -5px rgba(0, 0, 0, 0.3);
border-radius: 6px;
padding: 6px;
min-width: 200px;
z-index: 100;
font-size: 14px;
}
.react-contexify__submenu--is-open > .react-contexify__submenu {
pointer-events: initial;
opacity: 1;
}
.react-contexify .react-contexify__submenu {
position: absolute;
/* negate padding */
top: -6px;
pointer-events: none;
transition: opacity 0.275s;
}
.react-contexify__submenu-arrow {
margin-left: auto;
font-size: 12px;
}
.react-contexify__separator {
width: 100%;
height: 1px;
cursor: default;
margin: 4px 0;
background-color: rgba(0, 0, 0, 0.2);
}
.react-contexify__will-leave--disabled {
pointer-events: none;
}
.react-contexify__item {
cursor: pointer;
position: relative;
}
.react-contexify__item:focus {
outline: 0;
}
.menu-item:hover {
color: white;
background-color: #7289da;
border-radius: 2px;
}
.delete-item {
color: #f04747 !important;
}
.delete-item:hover {
color: white;
background-color: #f04747;
border-radius: 2px;
}
.react-contexify__item:not(.react-contexify__item--disabled):hover > .react-contexify__submenu {
pointer-events: initial;
opacity: 1;
}
.react-contexify__item--disabled {
cursor: default;
opacity: 0.5;
}
.react-contexify__item__content {
padding: 6px 12px;
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
white-space: nowrap;
color: #333;
position: relative;
}
.react-contexify__theme--dark {
background-color: rgba(40, 40, 40, 0.98);
}
.react-contexify__theme--dark .react-contexify__submenu {
background-color: rgba(40, 40, 40, 0.98);
}
.react-contexify__theme--dark .react-contexify__separator {
background-color: #eee;
}
.react-contexify__item__content {
color: #ffffff;
}
.react-contexify__theme--light .react-contexify__separator {
background-color: #eee;
}
.react-contexify__theme--light .react-contexify__submenu--is-open,
.react-contexify__theme--light .react-contexify__submenu--is-open > .react-contexify__item__content {
color: #4393e6;
background-color: #e0eefd;
}
.react-contexify__theme--light
.react-contexify__item:not(.react-contexify__item--disabled):hover
> .react-contexify__item__content,
.react-contexify__theme--light
.react-contexify__item:not(.react-contexify__item--disabled):focus
> .react-contexify__item__content {
color: #4393e6;
background-color: #e0eefd;
}
.react-contexify__theme--light .react-contexify__item__content {
color: #666;
}
@keyframes react-contexify__scaleIn {
from {
opacity: 0;
transform: scale3d(0.3, 0.3, 0.3);
}
to {
opacity: 1;
}
}
@keyframes react-contexify__scaleOut {
from {
opacity: 1;
}
to {
opacity: 0;
transform: scale3d(0.3, 0.3, 0.3);
}
}
.react-contexify__will-enter--scale {
transform-origin: top left;
animation: react-contexify__scaleIn 0.3s;
}
.react-contexify__will-leave--scale {
transform-origin: top left;
animation: react-contexify__scaleOut 0.3s;
}
@keyframes react-contexify__fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes react-contexify__fadeOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(10px);
}
}
.react-contexify__will-enter--fade {
animation: react-contexify__fadeIn 0.3s ease;
}
.react-contexify__will-leave--fade {
animation: react-contexify__fadeOut 0.3s ease;
}
@keyframes react-contexify__flipInX {
from {
transform: perspective(800px) rotate3d(1, 0, 0, 45deg);
}
to {
transform: perspective(800px);
}
}
@keyframes react-contexify__flipOutX {
from {
transform: perspective(800px);
}
to {
transform: perspective(800px) rotate3d(1, 0, 0, 45deg);
opacity: 0;
}
}
.react-contexify__will-enter--flip {
-webkit-backface-visibility: visible !important;
backface-visibility: visible !important;
transform-origin: top center;
animation: react-contexify__flipInX 0.3s;
}
.react-contexify__will-leave--flip {
transform-origin: top center;
animation: react-contexify__flipOutX 0.3s;
-webkit-backface-visibility: visible !important;
backface-visibility: visible !important;
}
@keyframes swing-in-top-fwd {
0% {
transform: rotateX(-100deg);
transform-origin: top;
opacity: 0;
}
100% {
transform: rotateX(0deg);
transform-origin: top;
opacity: 1;
}
}
@keyframes react-contexify__slideIn {
from {
opacity: 0;
transform: scale3d(1, 0.3, 1);
}
to {
opacity: 1;
}
}
@keyframes react-contexify__slideOut {
from {
opacity: 1;
}
to {
opacity: 0;
transform: scale3d(1, 0.3, 1);
}
}
.react-contexify__will-enter--slide {
transform-origin: top center;
animation: react-contexify__slideIn 0.3s;
}
.react-contexify__will-leave--slide {
transform-origin: top center;
animation: react-contexify__slideOut 0.3s;
}
================================================
FILE: web/src/components/items/message/Message.tsx
================================================
import React, { useState } from 'react';
import { Avatar, Box, Flex, Icon, Text, useDisclosure } from '@chakra-ui/react';
import { Item, Menu, theme, useContextMenu } from 'react-contexify';
import { useParams } from 'react-router-dom';
import { MdEdit } from 'react-icons/md';
import { FaEllipsisH, FaRegTrashAlt } from 'react-icons/fa';
import { FiLink } from 'react-icons/fi';
import { MessageContent } from './MessageContent';
import { userStore } from '../../../lib/stores/userStore';
import { getShortenedTime, getTime } from '../../../lib/utils/dateUtils';
import { DeleteMessageModal } from '../../modals/DeleteMessageModal';
import { EditMessageModal } from '../../modals/EditMessageModal';
import { useGetCurrentGuild } from '../../../lib/utils/hooks/useGetCurrentGuild';
import { MemberContextMenu } from '../../menus/MemberContextMenu';
import { UserPopover } from '../../sections/UserPopover';
import { RouterProps } from '../../../lib/models/routerProps';
import { Message as MessageResponse } from '../../../lib/models/message';
import '../css/ContextMenu.css';
interface MessageProps {
message: MessageResponse;
isCompact?: boolean;
}
export const Message: React.FC = ({ message, isCompact = false }) => {
const [showSettings, setShowSettings] = useState(false);
const current = userStore((state) => state.current);
const isAuthor = current?.id === message.user.id;
const { guildId } = useParams() as RouterProps;
const guild = useGetCurrentGuild(guildId);
const isOwner = guild !== undefined && guild.ownerId === current?.id;
const showMenu = isAuthor || isOwner || message.attachment?.url;
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure();
const id = `${message.user.id}-${Math.random().toString(36).substr(2, 5)}`;
const { show } = useContextMenu({
id: message.id,
});
const { show: profileShow } = useContextMenu({ id });
const openInNewTab = (url: string): void => {
const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
if (newWindow) newWindow.opener = null;
};
return (
<>
setShowSettings(false)}
onMouseEnter={() => setShowSettings(true)}
>
{isCompact ? (
<>
{getShortenedTime(message.createdAt)}
{showSettings && showMenu ? (
) : (
)}
>
) : (
<>
{
if (!isAuthor) profileShow(e);
}}
/>
{message.user.nickname ?? message.user.username}
{getTime(message.createdAt)}
{showSettings && showMenu && (
)}
>
)}
{showMenu && (
<>
{message.attachment?.filetype ? (
- {
if (message.attachment?.url) openInNewTab(message.attachment.url);
}}
>
Open Link
) : (
isAuthor && (
-
Edit Message
)
)}
{(isAuthor || isOwner) && (
-
Delete Message
)}
{isDeleteOpen && }
{isEditOpen && }
>
)}
{!isAuthor && }
>
);
};
================================================
FILE: web/src/components/items/message/MessageContent.tsx
================================================
import React from 'react';
import { Box, Flex, Image, Text } from '@chakra-ui/react';
import { Message } from '../../../lib/models/message';
interface MessageProps {
message: Message;
}
export const MessageContent: React.FC = ({ message: { attachment, text, createdAt, updatedAt } }) => {
if (attachment) {
const { filetype, url } = attachment;
if (filetype.startsWith('image/')) {
return (
);
}
if (filetype.startsWith('audio/')) {
return (
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
);
}
}
return (
{text}
{createdAt !== updatedAt && (
(edited)
)}
);
};
================================================
FILE: web/src/components/layouts/AccountBar.tsx
================================================
import { Avatar, Flex, IconButton, Text, Tooltip, useClipboard } from '@chakra-ui/react';
import React from 'react';
import { MdHeadset, MdHeadsetOff, MdMic, MdMicOff } from 'react-icons/md';
import { RiSettings5Fill } from 'react-icons/ri';
import { Link } from 'react-router-dom';
import { userStore } from '../../lib/stores/userStore';
import { voiceStore } from '../../lib/stores/voiceStore';
export const AccountBar: React.FC = () => {
const user = userStore((state) => state.current);
const [isMuted, isDeafened, setIsMuted, setIsDeafened] = voiceStore((state) => [
state.isMuted,
state.isDeafened,
state.setIsMuted,
state.setIsDeafened,
]);
const { hasCopied, onCopy } = useClipboard(user?.id || '');
const handleMute = (): void => {
setIsMuted(!isMuted);
// socket.send(
// JSON.stringify({
// action: 'toggle-mute',
// room: guildId,
// message: { id: user?.id, value: !isMuted },
// })
// );
};
const handleDeafen = (): void => {
setIsDeafened(!isDeafened);
// socket.send(
// JSON.stringify({
// action: 'toggle-deafen',
// room: guildId,
// message: { id: user?.id, value: !isDeafened },
// })
// );
};
return (
{user?.username}
: }
aria-label="toggle mute mic"
size="sm"
fontSize="22px"
variant="ghost"
onClick={() => handleMute()}
/>
: }
aria-label="toggle deafen audio"
size="sm"
fontSize="20px"
variant="ghost"
onClick={() => handleDeafen()}
/>
} aria-label="settings" size="sm" fontSize="20px" variant="ghost" />
);
};
================================================
FILE: web/src/components/layouts/AppLayout.tsx
================================================
import { Grid } from '@chakra-ui/react';
import React from 'react';
interface AppLayoutProps {
showLastColumn?: boolean | null;
children: React.ReactNode;
}
export const AppLayout: React.FC = ({ showLastColumn = false, children }) => (
// Col Chat: GuildList ChannelList Chat [MemberList]
// Col Home: GuildList DMList Chat/FriendsList
{children}
);
================================================
FILE: web/src/components/layouts/LandingLayout.tsx
================================================
import React from 'react';
import { Flex } from '@chakra-ui/react';
import { NavBar } from '../sections/NavBar';
import { Footer } from '../sections/Footer';
interface IProps {
children: React.ReactNode;
}
export const LandingLayout: React.FC = ({ children }) => (
{children}
);
================================================
FILE: web/src/components/layouts/VoiceBar.tsx
================================================
import { Box, Flex, Icon, IconButton, Text, Tooltip } from '@chakra-ui/react';
import React from 'react';
import { AiFillSignal } from 'react-icons/ai';
import { HiPhoneMissedCall } from 'react-icons/hi';
import { voiceStore } from '../../lib/stores/voiceStore';
import { useGetCurrentGuild } from '../../lib/utils/hooks/useGetCurrentGuild';
export const VoiceBar: React.FC = () => {
const [voiceChatID, inVC, leaveVoice] = voiceStore((state) => [state.voiceChatID, state.inVC, state.leaveVoice]);
const guild = useGetCurrentGuild(voiceChatID);
if (!inVC) return ;
return (
Voice Connected
General / {guild?.name}
}
aria-label="disconnect from call"
size="sm"
fontSize="20px"
variant="ghost"
onClick={() => leaveVoice()}
/>
);
};
================================================
FILE: web/src/components/layouts/guild/ChannelHeader.tsx
================================================
import React from 'react';
import { Flex, GridItem, Icon, Text } from '@chakra-ui/react';
import { FaHashtag } from 'react-icons/fa';
import { BsPeopleFill } from 'react-icons/bs';
import { useParams } from 'react-router-dom';
import { settingsStore } from '../../../lib/stores/settingsStore';
import { useGetCurrentChannel } from '../../../lib/utils/hooks/useGetCurrentChannel';
import { RouterProps } from '../../../lib/models/routerProps';
export const ChannelHeader: React.FC = () => {
const toggleMemberList = settingsStore((state) => state.toggleShowMembers);
const { guildId, channelId } = useParams() as RouterProps;
const channel = useGetCurrentChannel(channelId, guildId);
return (
{channel?.name}
);
};
================================================
FILE: web/src/components/layouts/guild/Channels.tsx
================================================
import React from 'react';
import { Box, GridItem, UnorderedList, useDisclosure } from '@chakra-ui/react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { AccountBar } from '../AccountBar';
import { CreateChannelModal } from '../../modals/CreateChannelModal';
import { GuildMenu } from '../../menus/GuildMenu';
import { InviteModal } from '../../modals/InviteModal';
import { ChannelListItem } from '../../items/ChannelListItem';
import { cKey } from '../../../lib/utils/querykeys';
import { channelScrollbarCss } from './css/ChannelScrollerCSS';
import { useChannelSocket } from '../../../lib/api/ws/useChannelSocket';
import { getChannels } from '../../../lib/api/handler/channel';
import { RouterProps } from '../../../lib/models/routerProps';
import { VoiceChat } from './VoiceChat';
import { VoiceBar } from '../VoiceBar';
export const Channels: React.FC = () => {
const { isOpen: inviteIsOpen, onOpen: inviteOpen, onClose: inviteClose } = useDisclosure();
const { isOpen: channelIsOpen, onOpen: channelOpen, onClose: channelClose } = useDisclosure();
const { guildId } = useParams() as RouterProps;
const { data } = useQuery([cKey, guildId], () => getChannels(guildId).then((response) => response.data));
useChannelSocket(guildId);
return (
<>
{inviteIsOpen && }
{channelIsOpen && }
{data?.map((c) => (
))}
>
);
};
================================================
FILE: web/src/components/layouts/guild/GuildList.tsx
================================================
import React from 'react';
import { Box, Divider, Flex, GridItem, UnorderedList, useDisclosure } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { AddGuildModal } from '../../modals/AddGuildModal';
import { GuildListItem } from '../../items/GuildListItem';
import { AddGuildIcon } from '../../sections/AddGuildIcon';
import { HomeIcon } from '../../sections/HomeIcon';
import { getUserGuilds } from '../../../lib/api/handler/guilds';
import { gKey, nKey } from '../../../lib/utils/querykeys';
import { guildScrollbarCss } from './css/GuildScrollerCSS';
import { useGuildSocket } from '../../../lib/api/ws/useGuildSocket';
import { NotificationListItem } from '../../items/NotificationListItem';
import { DMNotification } from '../../../lib/models/dm';
export const GuildList: React.FC = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { data } = useQuery([gKey], () => getUserGuilds().then((response) => response.data), {
cacheTime: Infinity,
});
const { data: dmData } = useQuery([nKey], () => [], {
cacheTime: Infinity,
});
useGuildSocket();
return (
{dmData?.map((dm) => (
))}
{data?.map((g) => (
))}
{isOpen && }
);
};
================================================
FILE: web/src/components/layouts/guild/MemberList.tsx
================================================
import React from 'react';
import { GridItem, UnorderedList } from '@chakra-ui/react';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { MemberListItem } from '../../items/MemberListItem';
import { getGuildMembers } from '../../../lib/api/handler/guilds';
import { mKey } from '../../../lib/utils/querykeys';
import { memberScrollbarCss } from './css/MemberScrollerCSS';
import { useMemberSocket } from '../../../lib/api/ws/useMemberSocket';
import { OnlineLabel } from '../../sections/OnlineLabel';
import { RouterProps } from '../../../lib/models/routerProps';
import { Member } from '../../../lib/models/member';
export const MemberList: React.FC = () => {
const { guildId } = useParams() as RouterProps;
const key = [mKey, guildId];
const { data } = useQuery(key, () => getGuildMembers(guildId).then((response) => response.data));
const online: Member[] = [];
const offline: Member[] = [];
if (data) {
data.forEach((m) => {
if (m.isOnline) {
online.push(m);
} else {
offline.push(m);
}
});
}
useMemberSocket(guildId);
return (
{online.map((m) => (
))}
{offline.map((m) => (
))}
);
};
================================================
FILE: web/src/components/layouts/guild/VoiceChat.tsx
================================================
import { Box, Flex, Icon, ListItem, Text } from '@chakra-ui/react';
import React from 'react';
import { FaVolumeUp } from 'react-icons/fa';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { getVCMembers } from '../../../lib/api/handler/guilds';
import { useVoiceSocket } from '../../../lib/api/ws/useVoiceSocket';
import { RouterProps } from '../../../lib/models/routerProps';
import { userStore } from '../../../lib/stores/userStore';
import { voiceStore } from '../../../lib/stores/voiceStore';
import { useSetupVoiceChat } from '../../../lib/utils/hooks/useVoiceChat';
import { vcKey } from '../../../lib/utils/querykeys';
import { VoiceChannelItem } from '../../items/VoiceChannelItem';
export const VoiceChat: React.FC<{}> = () => {
const { guildId } = useParams() as RouterProps;
const current = userStore((state) => state.current);
const key = [vcKey, guildId];
const [voiceChatID, setVoiceID] = voiceStore((state) => [state.voiceChatID, state.setVoiceID]);
const [inVC, setIsInVC] = voiceStore((state) => [state.inVC, state.setInVC]);
const voiceClients = voiceStore((state) => state.voiceClients);
const [localStream, setLocalStream] = voiceStore((state) => [state.localStream, state.setLocalStream]);
const [isMuted, isDeafened] = voiceStore((state) => [state.isMuted, state.isDeafened]);
const leaveVoice = voiceStore((state) => state.leaveVoice);
const { data } = useQuery(key, () => getVCMembers(guildId).then((response) => response.data));
useVoiceSocket();
useSetupVoiceChat(guildId);
const joinVoice = async (): Promise => {
if (guildId === voiceChatID) return;
if (voiceChatID !== '') {
leaveVoice();
}
await openMic();
setIsInVC(true);
setVoiceID(guildId);
};
const openMic = async (): Promise => {
try {
// Get audio device and set better audio settings
const result = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: false,
channelCount: 2,
echoCancellation: false,
noiseSuppression: false,
sampleRate: 48000,
sampleSize: 16,
},
});
setLocalStream(result);
} catch (err) {}
};
return (
joinVoice()}
>
General
{/* Current user */}
{inVC && localStream && guildId === voiceChatID && (
)}
{/* User is in VC, render all other clients */}
{guildId === voiceChatID
? voiceClients.map(
(e) =>
e.id !== current?.id && (
)
)
: // User is not in VC, render all clients without the stream
data?.map((e) => (
))}
);
};
================================================
FILE: web/src/components/layouts/guild/chat/ChatGrid.tsx
================================================
import React from 'react';
import { GridItem } from '@chakra-ui/react';
import { scrollbarCss } from '../../../../lib/utils/theme';
interface IProps {
children: React.ReactNode;
}
export const ChatGrid: React.FC = ({ children }) => (
{children}
);
================================================
FILE: web/src/components/layouts/guild/chat/ChatScreen.tsx
================================================
import React, { useState } from 'react';
import { Box, Flex, Spinner } from '@chakra-ui/react';
import InfiniteScroll from 'react-infinite-scroll-component';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { Message } from '../../../items/message/Message';
import { StartMessages } from '../../../sections/StartMessages';
import { getMessages } from '../../../../lib/api/handler/messages';
import { checkNewDay, getTimeDifference } from '../../../../lib/utils/dateUtils';
import { guildScrollbarCss } from '../css/GuildScrollerCSS';
import { useMessageSocket } from '../../../../lib/api/ws/useMessageSocket';
import { DateDivider } from '../../../sections/DateDivider';
import { ChatGrid } from './ChatGrid';
import { RouterProps } from '../../../../lib/models/routerProps';
import { Message as MessageResponse } from '../../../../lib/models/message';
import { msgKey } from '../../../../lib/utils/querykeys';
export const ChatScreen: React.FC = () => {
const { channelId } = useParams() as RouterProps;
const [hasMore, setHasMore] = useState(true);
const { data, isLoading, fetchNextPage } = useInfiniteQuery(
[msgKey, channelId],
async ({ pageParam = null }) => {
const { data: messageData } = await getMessages(channelId, pageParam);
if (messageData.length !== 35) setHasMore(false);
return messageData;
},
{
staleTime: 0,
cacheTime: 0,
getNextPageParam: (lastPage) => (hasMore && lastPage.length ? lastPage[lastPage.length - 1].createdAt : ''),
}
);
useMessageSocket(channelId);
if (isLoading) {
return (
);
}
const checkIfWithinTime = (message1: MessageResponse, message2: MessageResponse): boolean => {
if (message1.user.id !== message2.user.id) return false;
if (message1.createdAt === message2.createdAt) return false;
return getTimeDifference(message1.createdAt, message2.createdAt) <= 5;
};
const messages = data ? data!.pages.map((p) => p.map((mr) => mr)).flat() : [];
return (
fetchNextPage()}
style={{
display: 'flex',
flexDirection: 'column-reverse',
}}
inverse
hasMore={hasMore}
loader={
messages.length > 0 && (
)
}
scrollableTarget="chatGrid"
>
{messages.map((m, i) => (
{checkNewDay(m.createdAt, messages[Math.min(i + 1, messages.length - 1)].createdAt) && (
)}
))}
{!hasMore && }
);
};
================================================
FILE: web/src/components/layouts/guild/chat/FileUploadButton.tsx
================================================
import {
Icon,
InputLeftElement,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Progress,
Text,
useDisclosure,
} from '@chakra-ui/react';
import React, { useRef, useState } from 'react';
import { MdAddCircle } from 'react-icons/md';
import { useParams } from 'react-router-dom';
import { sendMessage } from '../../../../lib/api/handler/messages';
import { FileSchema } from '../../../../lib/utils/validation/message.schema';
import { StyledTooltip } from '../../../sections/StyledTooltip';
import { RouterProps } from '../../../../lib/models/routerProps';
export const FileUploadButton: React.FC = () => {
const { channelId } = useParams() as RouterProps;
const { isOpen, onOpen, onClose } = useDisclosure();
const inputFile: any = useRef(null);
const [isSubmitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState(0);
const [errors, setErrors] = useState({});
const disable = process.env.NODE_ENV === 'production';
const closeModal = (): void => {
setErrors({});
setProgress(0);
onClose();
};
const handleSubmit = async (file: File): Promise => {
if (!file) return;
setSubmitting(true);
try {
await FileSchema.validate({ file });
} catch (err: any) {
setErrors(err.errors);
onOpen();
return;
}
const data = new FormData();
data.append('file', file);
await sendMessage(channelId, data, (event: any) => {
const loaded = Math.round((100 * event.loaded) / event.total);
setProgress(loaded);
if (loaded >= 100) setProgress(0);
});
};
return (
inputFile.current.click()}
>
{
if (!e.currentTarget.files) return;
handleSubmit(e.currentTarget.files[0]).then(() => {
setSubmitting(false);
e.target.value = '';
});
}}
/>
{errors && (
Error Uploading File
Reason: <>{errors}>
Max file size is 5.00 MB
Only Images and mp3 allowed
)}
{progress > 0 && (
0} closeOnOverlayClick={false} onClose={closeModal} isCentered>
Upload Progress
)}
);
};
================================================
FILE: web/src/components/layouts/guild/chat/MessageInput.tsx
================================================
import React, { useRef, useState } from 'react';
import { Flex, GridItem, InputGroup, Text, Textarea } from '@chakra-ui/react';
import ResizeTextarea from 'react-textarea-autosize';
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { FileUploadButton } from './FileUploadButton';
import { sendMessage } from '../../../../lib/api/handler/messages';
import { getSameSocket } from '../../../../lib/api/getSocket';
import { userStore } from '../../../../lib/stores/userStore';
import { channelStore } from '../../../../lib/stores/channelStore';
import { cKey, dmKey } from '../../../../lib/utils/querykeys';
import { RouterProps } from '../../../../lib/models/routerProps';
import '../css/MessageInput.css';
export const MessageInput: React.FC = () => {
const [text, setText] = useState('');
const [isSubmitting, setSubmitting] = useState(false);
const [currentlyTyping, setCurrentlyTyping] = useState(false);
const inputRef: any = useRef();
const { guildId, channelId } = useParams() as RouterProps;
const qKey = guildId === undefined ? [dmKey] : [cKey, guildId];
const { data } = useQuery(qKey);
const channel = data?.find((c) => c.id === channelId);
const socket = getSameSocket();
const current = userStore((state) => state.current);
const isTyping = channelStore((state) => state.typing);
const handleSubmit = async (): Promise => {
if (!text || !text.trim()) {
return;
}
socket.send(
JSON.stringify({
action: 'stopTyping',
room: channelId,
message: current?.username,
})
);
try {
setSubmitting(true);
setCurrentlyTyping(false);
const formData = new FormData();
formData.append('text', text.trim());
await sendMessage(channelId, formData);
} catch (err) {}
};
const getTypingString = (members: string[]): string => {
switch (members.length) {
case 1:
return members[0];
case 2:
return `${members[0]} and ${members[1]}`;
case 3:
return `${members[0]}, ${members[1]} and ${members[2]}`;
default:
return 'Several people';
}
};
const getPlaceholder = (): string => {
if (!channel) return '';
if (channel?.user) {
return `Message @${channel?.user.username}`;
}
return `Message #${channel?.name}`;
};
return (
0 ? '0' : '26px'} bg="brandGray.light">
{isTyping.length > 0 && (
{getTypingString(isTyping)}
{isTyping.length === 1 ? 'is' : 'are'} typing...
)}
);
};
================================================
FILE: web/src/components/layouts/guild/css/ChannelScrollerCSS.ts
================================================
export const channelScrollbarCss = {
'&::-webkit-scrollbar': {
width: '4px',
},
'&::-webkit-scrollbar-track': {
width: '4px',
},
'&::-webkit-scrollbar-thumb': {
background: 'brandGray.darker',
borderRadius: '18px',
},
};
================================================
FILE: web/src/components/layouts/guild/css/GuildScrollerCSS.ts
================================================
export const guildScrollbarCss = {
'&::-webkit-scrollbar': {
width: '0',
},
};
================================================
FILE: web/src/components/layouts/guild/css/MemberScrollerCSS.ts
================================================
export const memberScrollbarCss = {
'&::-webkit-scrollbar': {
width: '4px',
},
'&::-webkit-scrollbar-track': {
width: '4px',
},
'&::-webkit-scrollbar-thumb': {
background: 'brandGray.darker',
borderRadius: '18px',
},
};
================================================
FILE: web/src/components/layouts/guild/css/MessageInput.css
================================================
.typing-indicator {
border-radius: 50px;
display: table;
position: relative;
-webkit-animation: 2s bulge infinite ease-out;
animation: 2s bulge infinite ease-out;
}
.typing-indicator span {
height: 7px;
width: 7px;
float: left;
margin: 0 1px;
background-color: #fff;
display: block;
border-radius: 50%;
opacity: 0.4;
}
.typing-indicator span:nth-of-type(1) {
-webkit-animation: 1s blink infinite 0.3333s;
animation: 1s blink infinite 0.3333s;
}
.typing-indicator span:nth-of-type(2) {
-webkit-animation: 1s blink infinite 0.6666s;
animation: 1s blink infinite 0.6666s;
}
.typing-indicator span:nth-of-type(3) {
-webkit-animation: 1s blink infinite 0.9999s;
animation: 1s blink infinite 0.9999s;
}
@-webkit-keyframes blink {
50% {
opacity: 1;
}
}
@keyframes blink {
50% {
opacity: 1;
}
}
@-webkit-keyframes bulge {
50% {
-webkit-transform: scale(1.05);
transform: scale(1.05);
}
}
@keyframes bulge {
50% {
-webkit-transform: scale(1.05);
transform: scale(1.05);
}
}
================================================
FILE: web/src/components/layouts/home/DMHeader.tsx
================================================
import React from 'react';
import { Box, Flex, GridItem, Icon, Text } from '@chakra-ui/react';
import { FaAt } from 'react-icons/fa';
import { useParams } from 'react-router-dom';
import { useGetCurrentDM } from '../../../lib/utils/hooks/useGetCurrentDM';
import { RouterProps } from '../../../lib/models/routerProps';
export const DMHeader: React.FC = () => {
const { channelId } = useParams() as RouterProps;
const channel = useGetCurrentDM(channelId);
return (
{channel?.user.username}
);
};
================================================
FILE: web/src/components/layouts/home/DMSidebar.tsx
================================================
import React from 'react';
import { GridItem, Box, Text, UnorderedList } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { AccountBar } from '../AccountBar';
import { FriendsListButton } from '../../sections/FriendsListButton';
import { DMListItem } from '../../items/DMListItem';
import { getUserDMs } from '../../../lib/api/handler/dm';
import { dmKey } from '../../../lib/utils/querykeys';
import { dmScrollerCss } from './css/dmScrollerCSS';
import { useDMSocket } from '../../../lib/api/ws/useDMSocket';
import { DMPlaceholder } from '../../sections/DMPlaceholder';
export const DMSidebar: React.FC = () => {
const { data } = useQuery([dmKey], () => getUserDMs().then((result) => result.data));
useDMSocket();
return (
DIRECT MESSAGES
{data?.map((dm) => (
))}
{data?.length === 0 && (
)}
);
};
================================================
FILE: web/src/components/layouts/home/css/dmScrollerCSS.ts
================================================
export const dmScrollerCss = {
'&::-webkit-scrollbar': {
width: '4px',
},
'&::-webkit-scrollbar-track': {
width: '4px',
},
'&::-webkit-scrollbar-thumb': {
background: 'brandGray.darker',
borderRadius: '18px',
},
};
================================================
FILE: web/src/components/layouts/home/dashboard/FriendsDashboard.tsx
================================================
import React from 'react';
import { GridItem } from '@chakra-ui/react';
import { FriendsListHeader } from './FriendsListHeader';
import { FriendsList } from './FriendsList';
import { PendingList } from './PendingList';
import { scrollbarCss } from '../../../../lib/utils/theme';
import { homeStore } from '../../../../lib/stores/homeStore';
export const FriendsDashboard: React.FC = () => {
const isPending = homeStore((state) => state.isPending);
return (
<>
{isPending ? : }
>
);
};
================================================
FILE: web/src/components/layouts/home/dashboard/FriendsList.tsx
================================================
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Flex, Text, UnorderedList } from '@chakra-ui/react';
import { fKey } from '../../../../lib/utils/querykeys';
import { getFriends } from '../../../../lib/api/handler/account';
import { OnlineLabel } from '../../../sections/OnlineLabel';
import { FriendsListItem } from '../../../items/FriendsListItem';
import { useFriendSocket } from '../../../../lib/api/ws/useFriendSocket';
export const FriendsList: React.FC = () => {
const { data } = useQuery([fKey], () => getFriends().then((response) => response.data));
useFriendSocket();
if (!data) return null;
if (data.length === 0) {
return (
No one here yet
);
}
return (
{data.map((f) => (
))}
);
};
================================================
FILE: web/src/components/layouts/home/dashboard/FriendsListHeader.tsx
================================================
import React from 'react';
import { Button, Flex, GridItem, Icon, LightMode, Text, useDisclosure } from '@chakra-ui/react';
import { FiUsers } from 'react-icons/fi';
import { AddFriendModal } from '../../../modals/AddFriendModal';
import { homeStore } from '../../../../lib/stores/homeStore';
import { PingIcon } from '../../../common/NotificationIcon';
export const FriendsListHeader: React.FC = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const toggle = homeStore((state) => state.toggleDisplay);
const isPending = homeStore((state) => state.isPending);
const requests = homeStore((state) => state.requestCount);
return (
Friends
{
if (isPending) toggle();
}}
variant={!isPending ? 'solid' : 'ghost'}
_focus={{ boxShadow: 'none' }}
>
Friends
{
if (!isPending) toggle();
}}
_focus={{ boxShadow: 'none' }}
>
Pending
{requests > 0 && }
Add Friend
{isOpen && }
);
};
================================================
FILE: web/src/components/layouts/home/dashboard/PendingList.tsx
================================================
import React, { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Flex, UnorderedList, Text } from '@chakra-ui/react';
import { rKey } from '../../../../lib/utils/querykeys';
import { getPendingRequests } from '../../../../lib/api/handler/account';
import { OnlineLabel } from '../../../sections/OnlineLabel';
import { RequestListItem } from '../../../items/RequestListItem';
import { homeStore } from '../../../../lib/stores/homeStore';
import { useRequestSocket } from '../../../../lib/api/ws/useRequestSocket';
export const PendingList: React.FC = () => {
const { data } = useQuery([rKey], () => getPendingRequests().then((response) => response.data), {
staleTime: 0,
});
useRequestSocket();
const reset = homeStore((state) => state.resetRequest);
useEffect(() => {
reset();
});
if (!data) return null;
if (data.length === 0) {
return (
There are no pending friend requests
);
}
return (
{data.map((r) => (
))}
);
};
================================================
FILE: web/src/components/menus/GuildMenu.tsx
================================================
import React from 'react';
import { Flex, GridItem, Heading, Icon, Menu, MenuButton, MenuDivider, useDisclosure } from '@chakra-ui/react';
import { FiChevronDown, FiX } from 'react-icons/fi';
import { FaUserEdit, FaUserPlus } from 'react-icons/fa';
import { MdAddCircle } from 'react-icons/md';
import { HiLogout } from 'react-icons/hi';
import { RiSettings5Fill } from 'react-icons/ri';
import { useNavigate, useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { StyledMenuList } from './StyledMenuList';
import { StyledMenuItem, StyledRedMenuItem } from './StyledMenuItem';
import { leaveGuild } from '../../lib/api/handler/guilds';
import { userStore } from '../../lib/stores/userStore';
import { useGetCurrentGuild } from '../../lib/utils/hooks/useGetCurrentGuild';
import { GuildSettingsModal } from '../modals/GuildSettingsModal';
import { EditMemberModal } from '../modals/EditMemberModal';
import { gKey } from '../../lib/utils/querykeys';
import { RouterProps } from '../../lib/models/routerProps';
import { Guild } from '../../lib/models/guild';
interface GuildMenuProps {
channelOpen: () => void;
inviteOpen: () => void;
}
export const GuildMenu: React.FC = ({ channelOpen, inviteOpen }) => {
const { guildId } = useParams() as RouterProps;
const guild = useGetCurrentGuild(guildId);
const navigate = useNavigate();
const cache = useQueryClient();
const user = userStore((state) => state.current);
const isOwner = guild?.ownerId === user?.id;
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: memberOpen, onOpen: memberOnOpen, onClose: memberOnClose } = useDisclosure();
const handleLeave = async (): Promise => {
try {
const { data } = await leaveGuild(guildId);
if (data) {
cache.setQueryData([gKey], (d) => d?.filter((g) => g.id !== guild?.id) ?? []);
navigate('/channels/me', { replace: true });
}
} catch (err) {}
};
return (
{({ isOpen: menuIsOpen }) => (
<>
{guild?.name}
{isOwner && }
{isOwner && }
{!isOwner && (
<>
>
)}
>
)}
{isOpen && }
{memberOpen && }
);
};
================================================
FILE: web/src/components/menus/MemberContextMenu.tsx
================================================
import React, { useState } from 'react';
import { Divider, Flex, Text, useDisclosure } from '@chakra-ui/react';
import { Item, Menu, theme } from 'react-contexify';
import { useNavigate } from 'react-router-dom';
import { getOrCreateDirectMessage } from '../../lib/api/handler/dm';
import { sendFriendRequest } from '../../lib/api/handler/account';
import { RemoveFriendModal } from '../modals/RemoveFriendModal';
import { ModActionModal } from '../modals/ModActionModal';
import { Member } from '../../lib/models/member';
interface MemberContextMenuProps {
member: Member;
isOwner: boolean;
id: string;
}
export const MemberContextMenu: React.FC = ({ member, isOwner, id }) => {
const navigate = useNavigate();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: modIsOpen, onOpen: modOnOpen, onClose: modOnClose } = useDisclosure();
const [isBan, setIsBan] = useState(false);
const getOrCreateDM = async (): Promise => {
try {
const { data } = await getOrCreateDirectMessage(member.id);
if (data) {
navigate(`/channels/me/${data.id}`);
}
} catch (err) {}
};
const handleFriendClick = async (): Promise => {
if (!member.isFriend) {
try {
await sendFriendRequest(member.id);
} catch (err) {}
} else {
onOpen();
}
};
return (
<>
- getOrCreateDM()} className="menu-item">
Message
-
{member.isFriend ? 'Remove' : 'Add'} Friend
{isOwner && (
<>
- {
setIsBan(false);
modOnOpen();
}}
className="delete-item"
>
Kick {member.username}
- {
setIsBan(true);
modOnOpen();
}}
className="delete-item"
>
Ban {member.username}
>
)}
{isOpen && }
{modIsOpen && }
>
);
};
================================================
FILE: web/src/components/menus/StyledMenuItem.tsx
================================================
import React from 'react';
import { Flex, Icon, MenuItem, Text } from '@chakra-ui/react';
import { IconType } from 'react-icons';
interface StyledMenuItemProps {
label: string;
icon: IconType;
handleClick: () => void;
}
export const StyledMenuItem: React.FC = ({ label, icon, handleClick }) => (
{label}
);
export const StyledRedMenuItem: React.FC = ({ label, icon, handleClick }) => (
{label}
);
================================================
FILE: web/src/components/menus/StyledMenuList.tsx
================================================
import { MenuList } from '@chakra-ui/react';
import React from 'react';
interface IProps {
children: React.ReactNode;
}
export const StyledMenuList: React.FC = ({ children }) => (
{children}
);
================================================
FILE: web/src/components/modals/AddFriendModal.tsx
================================================
import React from 'react';
import {
Button,
Input,
InputGroup,
InputLeftAddon,
InputRightElement,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
useClipboard,
} from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import { useQueryClient } from '@tanstack/react-query';
import { userStore } from '../../lib/stores/userStore';
import { InputField } from '../common/InputField';
import { sendFriendRequest } from '../../lib/api/handler/account';
import { rKey } from '../../lib/utils/querykeys';
interface AddFriendModalProps {
isOpen: boolean;
onClose: () => void;
}
export const AddFriendModal: React.FC = ({ isOpen, onClose }) => {
const current = userStore((state) => state.current);
const cache = useQueryClient();
const { hasCopied, onCopy } = useClipboard(current?.id || '');
return (
{
if (values.id === '' && values.id.length !== 20) {
setErrors({ id: 'Enter a valid ID' });
} else {
try {
const { data } = await sendFriendRequest(values.id);
if (data) {
onClose();
await cache.invalidateQueries([rKey]);
}
} catch (err: any) {
if (err?.response?.data?.error) {
const error = err?.response?.data?.error?.message;
setErrors({ id: error });
}
}
}
}}
>
{({ isSubmitting }) => (
)}
);
};
================================================
FILE: web/src/components/modals/AddGuildModal.tsx
================================================
import {
Button,
Divider,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
VStack,
} from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import React, { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { InputField } from '../common/InputField';
import { GuildSchema } from '../../lib/utils/validation/guild.schema';
import { createGuild, joinGuild } from '../../lib/api/handler/guilds';
import { userStore } from '../../lib/stores/userStore';
import { toErrorMap } from '../../lib/utils/toErrorMap';
import { gKey } from '../../lib/utils/querykeys';
import { Guild } from '../../lib/models/guild';
interface IProps {
isOpen: boolean;
onClose: () => void;
}
enum AddGuildScreen {
START,
INVITE,
CREATE,
}
export const AddGuildModal: React.FC = ({ isOpen, onClose }) => {
const [screen, setScreen] = useState(AddGuildScreen.START);
const goBack = (): void => setScreen(AddGuildScreen.START);
const submitClose = (): void => {
setScreen(AddGuildScreen.START);
onClose();
};
return (
{screen === AddGuildScreen.INVITE && }
{screen === AddGuildScreen.CREATE && }
{screen === AddGuildScreen.START && (
Create a server
Your server is where you and your friends hang out. Make yours and start talking.
setScreen(AddGuildScreen.CREATE)}
>
Create My Own
Have an invite already?
setScreen(AddGuildScreen.INVITE)}
>
Join a Server
)}
);
};
interface IScreenProps {
goBack: () => void;
submitClose: () => void;
}
const JoinServerModal: React.FC = ({ goBack, submitClose }) => {
const cache = useQueryClient();
const navigate = useNavigate();
return (
{
if (values.link === '') {
setErrors({ link: 'Enter a valid link' });
} else {
try {
const { data } = await joinGuild(values);
if (data) {
cache.setQueryData([gKey], (old) => [...(old ?? []), data]);
submitClose();
navigate(`/channels/${data.id}/${data.default_channel_id}`);
}
} catch (err: any) {
const status = err?.response?.status;
if (status === 400 || status === 500) {
setErrors({ link: err?.response?.data?.error.message });
}
}
}
}}
>
{({ isSubmitting }) => (
)}
);
};
const CreateServerModal: React.FC = ({ goBack, submitClose }) => {
const user = userStore((state) => state.current);
const cache = useQueryClient();
const navigate = useNavigate();
return (
{
try {
const { data } = await createGuild(values);
if (data) {
cache.setQueryData([gKey], (old) => [...(old ?? []), data]);
submitClose();
navigate(`/channels/${data.id}/${data.default_channel_id}`);
}
} catch (err: any) {
if (err?.response?.status === 400) {
setErrors({ name: 'The server limit is 100' });
}
if (err?.response?.data?.errors) {
const errors = err?.response?.data?.errors;
setErrors(toErrorMap(errors));
}
}
}}
>
{({ isSubmitting, values }) => (
)}
);
};
================================================
FILE: web/src/components/modals/ChangePasswordModal.tsx
================================================
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
useToast,
} from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import React from 'react';
import { toErrorMap } from '../../lib/utils/toErrorMap';
import { ChangePasswordSchema } from '../../lib/utils/validation/auth.schema';
import { InputField } from '../common/InputField';
import { changePassword } from '../../lib/api/handler/auth';
interface IProps {
isOpen: boolean;
onClose: () => void;
}
export const ChangePasswordModal: React.FC = ({ isOpen, onClose }) => {
const toast = useToast();
return (
{
try {
const { data } = await changePassword(values);
if (data) {
toast({
title: 'Changed Password',
status: 'success',
duration: 5000,
isClosable: true,
});
onClose();
}
} catch (err: any) {
if (err?.response?.status === 500) {
toast({
title: 'Server Error',
description: 'Try again later',
status: 'error',
duration: 3000,
isClosable: true,
});
}
if (err?.response?.data?.errors) {
const errors = err?.response?.data?.errors;
setErrors(toErrorMap(errors));
}
}
}}
>
{({ isSubmitting }) => (
)}
);
};
================================================
FILE: web/src/components/modals/ChannelSettingsModal.tsx
================================================
import {
Avatar,
Box,
Button,
Divider,
Flex,
FormControl,
FormLabel,
LightMode,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Switch,
Text,
} from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import React, { useState } from 'react';
import { AiOutlineLock } from 'react-icons/ai';
import { FaRegTrashAlt } from 'react-icons/fa';
import { CUIAutoComplete } from 'chakra-ui-autocomplete';
import { useQuery } from '@tanstack/react-query';
import { InputField } from '../common/InputField';
import { toErrorMap } from '../../lib/utils/toErrorMap';
import { getGuildMembers } from '../../lib/api/handler/guilds';
import { ChannelSchema } from '../../lib/utils/validation/channel.schema';
import { useGetCurrentChannel } from '../../lib/utils/hooks/useGetCurrentChannel';
import { mKey } from '../../lib/utils/querykeys';
import { deleteChannel, editChannel, getPrivateChannelMembers } from '../../lib/api/handler/channel';
interface IProps {
guildId: string;
channelId: string;
isOpen: boolean;
onClose: () => void;
}
interface Item {
// eslint-disable-next-line react/no-unused-prop-types
value: string;
label: string;
image: string;
}
enum ChannelScreen {
START,
CONFIRM,
}
const ListItem = ({ image, label }: Item): JSX.Element => (
{label}
);
export const ChannelSettingsModal: React.FC = ({ guildId, channelId, isOpen, onClose }) => {
const key = [mKey, guildId];
const { data } = useQuery(key, () => getGuildMembers(guildId).then((response) => response.data));
const channel = useGetCurrentChannel(channelId, guildId);
const members: Item[] = [];
const [selectedItems, setSelectedItems] = useState- ([]);
const [screen, setScreen] = useState(ChannelScreen.START);
const [showError, toggleShow] = useState(false);
const goBack = (): void => setScreen(ChannelScreen.START);
const submitClose = (): void => {
setScreen(ChannelScreen.START);
onClose();
};
data?.map((m) =>
members.push({
label: m.username,
value: m.id,
image: m.image,
})
);
// eslint-disable-next-line
const { data: _ } = useQuery
- (['pcmembers', channelId], async () => {
const { data: memberData } = await getPrivateChannelMembers(channelId);
const current = members.filter((m) => memberData.includes(m.value));
setSelectedItems(current);
return current;
});
const handleCreateItem = (item: Item): void => {
setSelectedItems((curr) => [...curr, item]);
};
const handleSelectedItemsChange = (changedItems?: Item[]): void => {
if (changedItems) {
setSelectedItems(changedItems);
}
};
if (!channel) return null;
return (
{screen === ChannelScreen.START && (
{
try {
const ids: string[] = [];
selectedItems.map((i) => ids.push(i.value));
const { data: responseData } = await editChannel(channelId, {
...values,
members: ids,
});
if (responseData) {
resetForm();
onClose();
}
} catch (err: any) {
if (err?.response?.status === 500) {
toggleShow(true);
}
if (err?.response?.data?.errors) {
const errors = err?.response?.data?.errors;
setErrors(toErrorMap(errors));
}
}
}}
>
{({ isSubmitting, setFieldValue, values }) => (
)}
)}
{screen === ChannelScreen.CONFIRM && (
)}
);
};
interface IScreenProps {
goBack: () => void;
submitClose: () => void;
name: string;
channelId: string;
}
const DeleteChannelModal: React.FC = ({ goBack, submitClose, name, channelId }) => {
const [showError, toggleShow] = useState(false);
const handleDelete = async (): Promise => {
try {
const { data } = await deleteChannel(channelId);
if (data) {
submitClose();
}
} catch (err) {
toggleShow(true);
}
};
return (
Delete Channel
Are you sure you want to delete #{name}? This cannot be undone.
{showError && (
Server Error. Try again later
)}
Cancel
handleDelete()}>
Delete Channel
);
};
================================================
FILE: web/src/components/modals/CreateChannelModal.tsx
================================================
import {
Avatar,
Box,
Button,
Flex,
FormControl,
FormLabel,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Switch,
Text,
} from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AiOutlineLock } from 'react-icons/ai';
import { CUIAutoComplete } from 'chakra-ui-autocomplete';
import { useNavigate } from 'react-router-dom';
import { InputField } from '../common/InputField';
import { toErrorMap } from '../../lib/utils/toErrorMap';
import { getGuildMembers } from '../../lib/api/handler/guilds';
import { ChannelSchema } from '../../lib/utils/validation/channel.schema';
import { mKey } from '../../lib/utils/querykeys';
import { createChannel } from '../../lib/api/handler/channel';
interface IProps {
guildId: string;
isOpen: boolean;
onClose: () => void;
}
interface Item {
// eslint-disable-next-line react/no-unused-prop-types
value: string;
label: string;
image: string;
}
const ListItem = ({ image, label }: Item): JSX.Element => (
{label}
);
export const CreateChannelModal: React.FC = ({ guildId, isOpen, onClose }) => {
const key = [mKey, guildId];
const navigate = useNavigate();
const { data } = useQuery(key, () => getGuildMembers(guildId).then((response) => response.data));
const [showError, toggleError] = useState(false);
const members: Item[] = [];
const [selectedItems, setSelectedItems] = useState- ([]);
data?.map((m) =>
members.push({
label: m.username,
value: m.id,
image: m.image,
})
);
const handleCreateItem = (item: Item): void => {
setSelectedItems((curr) => [...curr, item]);
};
const handleSelectedItemsChange = (changedItems?: Item[]): void => {
if (changedItems) {
setSelectedItems(changedItems);
}
};
return (
{
try {
const ids: string[] = [];
selectedItems.map((i) => ids.push(i.value));
const { data: responseData } = await createChannel(guildId, {
...values,
members: ids,
});
if (responseData) {
resetForm();
onClose();
navigate(`/channels/${guildId}/${responseData.id}`);
}
} catch (err: any) {
if (err?.response?.status === 500) {
toggleError(true);
}
if (err?.response?.data?.errors) {
const errors = err?.response?.data?.errors;
setErrors(toErrorMap(errors));
}
}
}}
>
{({ isSubmitting, setFieldValue, values }) => (
)}
);
};
================================================
FILE: web/src/components/modals/CropImageModal.tsx
================================================
import {
Box,
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Slider,
SliderFilledTrack,
SliderThumb,
SliderTrack,
} from '@chakra-ui/react';
import React, { useCallback, useState } from 'react';
import Cropper from 'react-easy-crop';
import getCroppedImg from '../../lib/utils/cropImage';
interface IProps {
isOpen: boolean;
initialImage: string;
applyCrop: (image: Blob) => void;
onClose: () => void;
}
export const CropImageModal: React.FC = ({ isOpen, onClose, applyCrop, initialImage }) => {
const [crop, setCrop] = useState({
x: 0,
y: 0,
});
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
const onCropComplete = useCallback((_: any, croppedAreaPixelsResult: any) => {
setCroppedAreaPixels(croppedAreaPixelsResult);
}, []);
const showCroppedImage = useCallback(async () => {
try {
const croppedImage = await getCroppedImg(initialImage, croppedAreaPixels);
applyCrop(croppedImage);
} catch (e) {}
}, [croppedAreaPixels, initialImage, applyCrop]);
return (
EDIT MEDIA
setZoom(value)}
my="4"
>
Cancel
Apply
);
};
================================================
FILE: web/src/components/modals/DeleteMessageModal.tsx
================================================
import {
Avatar,
Box,
Button,
Flex,
LightMode,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
} from '@chakra-ui/react';
import React, { useState } from 'react';
import { deleteMessage } from '../../lib/api/handler/messages';
import { getTime } from '../../lib/utils/dateUtils';
import { Message } from '../../lib/models/message';
interface IProps {
message: Message;
isOpen: boolean;
onClose: () => void;
}
export const DeleteMessageModal: React.FC = ({ message, isOpen, onClose }) => {
const [showError, toggleShow] = useState(false);
const handleDelete = async (): Promise => {
try {
const { data } = await deleteMessage(message.id);
if (data) {
onClose();
}
} catch (err) {
toggleShow(true);
}
};
return (
Delete Message
Are you sure you want to delete this message?
{message.user.username}
{getTime(message.createdAt)}
{message.attachment?.filename ?? message.text}
{showError && (
Server Error. Try again later
)}
Cancel
handleDelete()}>
Delete
);
};
================================================
FILE: web/src/components/modals/EditMemberModal.tsx
================================================
import {
Button,
Divider,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
} from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ColorResult, TwitterPicker } from 'react-color';
import { InputField } from '../common/InputField';
import { toErrorMap } from '../../lib/utils/toErrorMap';
import { userStore } from '../../lib/stores/userStore';
import { MemberSchema } from '../../lib/utils/validation/member.schema';
import { changeGuildMemberSettings, getGuildMemberSettings } from '../../lib/api/handler/members';
interface IProps {
guildId: string;
isOpen: boolean;
onClose: () => void;
}
export const EditMemberModal: React.FC = ({ guildId, isOpen, onClose }) => {
const current = userStore((state) => state.current);
const { data } = useQuery(['settings', guildId], () =>
getGuildMemberSettings(guildId).then((response) => response.data)
);
const [showError, toggleShow] = useState(false);
if (!data) return null;
return (
{
try {
// Default color --> Reset
if (values.color === '#fff') setFieldValue('color', null);
const { data: responseData } = await changeGuildMemberSettings(guildId, values);
if (responseData) {
onClose();
}
} catch (err: any) {
if (err?.response?.status === 500) {
toggleShow(true);
}
if (err?.response?.data?.errors) {
const errors = err?.response?.data?.errors;
setErrors(toErrorMap(errors));
}
}
}}
>
{({ isSubmitting, setFieldValue, values }) => (
)}
);
};
================================================
FILE: web/src/components/modals/EditMessageModal.tsx
================================================
import {
Avatar,
Box,
Button,
Flex,
Input,
LightMode,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
} from '@chakra-ui/react';
import React, { useState } from 'react';
import { editMessage } from '../../lib/api/handler/messages';
import { getTime } from '../../lib/utils/dateUtils';
import { Message } from '../../lib/models/message';
interface IProps {
message: Message;
isOpen: boolean;
onClose: () => void;
}
export const EditMessageModal: React.FC = ({ message, isOpen, onClose }) => {
const [text, setNewText] = useState(message.text!);
const [showError, toggleShow] = useState(false);
const handleSubmit = async (): Promise => {
if (!text || !text.trim()) return;
try {
await editMessage(message.id, text.trim());
onClose();
} catch (err) {
toggleShow(true);
}
};
return (
Edit Message
{message.user.username}
{getTime(message.createdAt)}
setNewText(e.target.value)}
bg="brandGray.dark"
borderColor="black"
borderRadius="3px"
focusBorderColor="none"
/>
{showError && (
Server Error. Try again later
)}
Cancel
Save
);
};
================================================
FILE: web/src/components/modals/GuildSettingsModal.tsx
================================================
import {
Avatar,
Box,
Button,
Divider,
Flex,
IconButton,
LightMode,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
Tooltip,
useDisclosure,
} from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import React, { useRef, useState } from 'react';
import { FaRegTrashAlt } from 'react-icons/fa';
import { IoCheckmarkCircle, IoPersonRemove } from 'react-icons/io5';
import { ImHammer2 } from 'react-icons/im';
import { BiUnlink } from 'react-icons/bi';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { InputField } from '../common/InputField';
import { toErrorMap } from '../../lib/utils/toErrorMap';
import { useGetCurrentGuild } from '../../lib/utils/hooks/useGetCurrentGuild';
import { GuildSchema } from '../../lib/utils/validation/guild.schema';
import { deleteGuild, editGuild, invalidateInviteLinks } from '../../lib/api/handler/guilds';
import { CropImageModal } from './CropImageModal';
import { channelScrollbarCss } from '../layouts/guild/css/ChannelScrollerCSS';
import { getBanList, unbanMember } from '../../lib/api/handler/members';
import { Member } from '../../lib/models/member';
interface IProps {
guildId: string;
isOpen: boolean;
onClose: () => void;
}
enum SettingsScreen {
START,
CONFIRM,
BANLIST,
}
export const GuildSettingsModal: React.FC = ({ guildId, isOpen, onClose }) => {
const guild = useGetCurrentGuild(guildId);
const [screen, setScreen] = useState(SettingsScreen.START);
const [isReset, setIsReset] = useState(false);
const [showError, toggleShow] = useState(false);
const goBack = (): void => setScreen(SettingsScreen.START);
const submitClose = (): void => {
setScreen(SettingsScreen.START);
onClose();
};
const { isOpen: cropperIsOpen, onOpen: cropperOnOpen, onClose: cropperOnClose } = useDisclosure();
const inputFile: any = useRef(null);
const [imageUrl, setImageUrl] = useState(guild?.icon || '');
const [cropImage, setCropImage] = useState('');
const [croppedImage, setCroppedImage] = useState(null);
const applyCrop = (file: Blob): void => {
setImageUrl(URL.createObjectURL(file));
setCroppedImage(new File([file], 'icon', { type: 'image/jpeg' }));
cropperOnClose();
};
if (!guild) return null;
const invalidateInvites = async (): Promise => {
try {
const { data } = await invalidateInviteLinks(guild!.id);
if (data) {
setIsReset(true);
}
} catch (err) {}
};
return (
{screen === SettingsScreen.START && (
{
try {
const formData = new FormData();
formData.append('name', values.name);
if (cropImage) {
formData.append('image', croppedImage);
} else if (imageUrl) {
formData.append('icon', imageUrl);
}
const { data } = await editGuild(guildId, formData);
if (data) {
resetForm();
onClose();
}
} catch (err: any) {
if (err?.response?.status === 500) {
toggleShow(true);
}
if (err?.response?.data?.errors) {
const errors = err?.response?.data?.errors;
setErrors(toErrorMap(errors));
}
}
}}
>
{({ isSubmitting }) => (
)}
{cropperIsOpen && (
)}
)}
{screen === SettingsScreen.CONFIRM && (
)}
{screen === SettingsScreen.BANLIST && }
);
};
interface IScreenProps {
goBack: () => void;
submitClose: () => void;
name: string;
guildId: string;
}
const DeleteGuildModal: React.FC = ({ goBack, submitClose, name, guildId }) => {
const [showError, toggleShow] = useState(false);
const handleDelete = async (): Promise => {
try {
const { data } = await deleteGuild(guildId);
if (data) {
submitClose();
}
} catch (err: any) {
if (err?.response?.status === 500) {
toggleShow(true);
}
}
};
return (
Delete {name}
Are you sure you want to delete {name} ? This cannot be undone.
{showError && (
Server Error. Try again later
)}
Cancel
handleDelete()}>
Delete Server
);
};
interface IBanScreenProps {
goBack: () => void;
guildId: string;
}
const BanListModal: React.FC = ({ goBack, guildId }) => {
const key = ['bans', guildId];
const { data } = useQuery(key, () => getBanList(guildId).then((response) => response.data));
const cache = useQueryClient();
const unbanUser = async (id: string): Promise => {
try {
const { data: responseData } = await unbanMember(guildId, id);
if (responseData) {
cache.setQueryData(key, (d) => d?.filter((b) => b.id !== id) ?? []);
}
} catch (err) {}
};
return (
{data?.length} Bans
Bans are by account. Click on the icon to unban.
{data?.map((m) => (
{m.username}
}
borderRadius="50%"
aria-label="unban user"
onClick={async (e) => {
e.preventDefault();
await unbanUser(m.id);
}}
/>
))}
{data?.length === 0 && No bans yet. }
Back
);
};
================================================
FILE: web/src/components/modals/InviteModal.tsx
================================================
import {
Button,
Checkbox,
Input,
InputGroup,
InputRightElement,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
useClipboard,
} from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { getInviteLink } from '../../lib/api/handler/guilds';
import { RouterProps } from '../../lib/models/routerProps';
interface InviteModalProps {
isOpen: boolean;
onClose: () => void;
}
export const InviteModal: React.FC = ({ isOpen, onClose }) => {
const { guildId } = useParams() as RouterProps;
const [inviteLink, setInviteLink] = useState('');
const { hasCopied, onCopy } = useClipboard(inviteLink);
const [isPermanent, setPermanent] = useState(false);
useEffect(() => {
if (isOpen) {
const fetchLink = async (): Promise => {
try {
const { data } = await getInviteLink(guildId, isPermanent);
if (data) setInviteLink(data);
} catch (err) {}
};
fetchLink();
}
}, [isOpen, setInviteLink, guildId, isPermanent]);
return (
Invite Link
Share this link with others to grant access to this server
setPermanent(e.target.checked)} mb={4}>
Make it unlimited / Never reset
{hasCopied ? 'Copied' : 'Copy'}
{isPermanent
? "Your invite link won't expire"
: 'Your invite link expires in 1 day and can only be used once'}
);
};
================================================
FILE: web/src/components/modals/ModActionModal.tsx
================================================
import {
Button,
LightMode,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
} from '@chakra-ui/react';
import React from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { mKey } from '../../lib/utils/querykeys';
import { Member } from '../../lib/models/member';
import { RouterProps } from '../../lib/models/routerProps';
import { banMember, kickMember } from '../../lib/api/handler/members';
interface IProps {
member: Member;
isOpen: boolean;
isBan: boolean;
onClose: () => void;
}
export const ModActionModal: React.FC = ({ member, isOpen, onClose, isBan }) => {
const cache = useQueryClient();
const action = isBan ? 'Ban ' : 'Kick ';
const { guildId } = useParams() as RouterProps;
return (
{action}'{member.username}'
Are you sure you want to {action.toLocaleLowerCase()} @{member.username}?
{!isBan && ' They will be able to rejoin again with a new invite.'}
Cancel
{
onClose();
try {
const { data } = isBan ? await banMember(guildId, member.id) : await kickMember(guildId, member.id);
if (data) {
cache.setQueryData([mKey, guildId], (d) => d!.filter((f) => f.id !== member.id) ?? []);
}
} catch (err) {}
}}
>
{action}
);
};
================================================
FILE: web/src/components/modals/RemoveFriendModal.tsx
================================================
import {
Button,
LightMode,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
} from '@chakra-ui/react';
import React from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { removeFriend } from '../../lib/api/handler/account';
import { fKey } from '../../lib/utils/querykeys';
import { Friend } from '../../lib/models/friend';
interface IProps {
member: Friend;
isOpen: boolean;
onClose: () => void;
}
export const RemoveFriendModal: React.FC = ({ member, isOpen, onClose }) => {
const cache = useQueryClient();
return (
Remove '{member?.username}'
Are you sure you want to permanently remove {member?.username} from your friends?
Cancel
{
onClose();
try {
const { data } = await removeFriend(member.id);
if (data) {
cache.setQueryData([fKey], (d) => d?.filter((f) => f.id !== member.id) ?? []);
}
} catch (err) {}
}}
>
Remove Friend
);
};
================================================
FILE: web/src/components/sections/AddGuildIcon.tsx
================================================
import React, { useState } from 'react';
import { Flex } from '@chakra-ui/react';
import { AiOutlinePlus } from 'react-icons/ai';
import { StyledTooltip } from './StyledTooltip';
import { HoverGuildPill } from '../common/GuildPills';
interface AddGuildIconProps {
onOpen: () => void;
}
export const AddGuildIcon: React.FC = ({ onOpen }) => {
const [isHover, setHover] = useState(false);
return (
<>
{isHover && }
setHover(false)}
onMouseEnter={() => setHover(true)}
>
>
);
};
================================================
FILE: web/src/components/sections/DMPlaceholder.tsx
================================================
import React from 'react';
import { Box, Flex } from '@chakra-ui/react';
export const DMPlaceholder: React.FC = () => (
);
================================================
FILE: web/src/components/sections/DateDivider.tsx
================================================
import { Divider, Flex, Text } from '@chakra-ui/react';
import React from 'react';
import { formatDivider } from '../../lib/utils/dateUtils';
interface DateDividerProps {
date: string;
}
export const DateDivider: React.FC = ({ date }) => (
{formatDivider(date)}
);
================================================
FILE: web/src/components/sections/Footer.tsx
================================================
import React from 'react';
import { Box, Flex, Link, Stack, Text } from '@chakra-ui/react';
import { AiOutlineApi, AiOutlineGithub } from 'react-icons/ai';
import { SiSocketdotio } from 'react-icons/si';
import { IconType } from 'react-icons';
import { StyledTooltip } from './StyledTooltip';
type FooterLinkProps = {
icon?: IconType;
href?: string;
label?: string;
};
const FooterLink: React.FC = ({ icon, href, label }) => (
);
const links = [
{
icon: AiOutlineGithub,
label: 'GitHub',
href: 'https://github.com/sentrionic/Valkyrie',
},
{
icon: AiOutlineApi,
label: 'REST API',
href: `${process.env.REACT_APP_API!}/swagger/index.html`,
},
{
icon: SiSocketdotio,
label: 'Websocket',
href: `${process.env.REACT_APP_API!}`,
},
];
export const Footer: React.FC = () => (
Valkyrie | 2022
This is not a real commercial app.
{links.map((link) => (
// eslint-disable-next-line react/jsx-props-no-spreading
))}
);
================================================
FILE: web/src/components/sections/FriendsListButton.tsx
================================================
import React from 'react';
import { Flex, Icon, Text } from '@chakra-ui/react';
import { FiUsers } from 'react-icons/fi';
import { Link, useLocation } from 'react-router-dom';
import { PingIcon } from '../common/NotificationIcon';
import { homeStore } from '../../lib/stores/homeStore';
export const FriendsListButton: React.FC = () => {
const currentPath = '/channels/me';
const location = useLocation();
const isActive = location.pathname === currentPath;
const requests = homeStore((state) => state.requestCount);
return (
Friends
{requests > 0 && }
);
};
================================================
FILE: web/src/components/sections/GlobalState.tsx
================================================
import React, { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { userStore } from '../../lib/stores/userStore';
import { getSocket } from '../../lib/api/getSocket';
import { homeStore } from '../../lib/stores/homeStore';
import { nKey } from '../../lib/utils/querykeys';
import { DMChannel, DMNotification } from '../../lib/models/dm';
type WSMessage = { action: 'new_dm_notification'; data: DMChannel } | { action: 'send_request' };
interface IProps {
children: React.ReactNode;
}
export const GlobalState: React.FC = ({ children }) => {
const current = userStore((state) => state.current);
const inc = homeStore((state) => state.increment);
const cache = useQueryClient();
// eslint-disable-next-line consistent-return
useEffect(() => {
if (current) {
const disconnect = (): void => {
socket.send(JSON.stringify({ action: 'toggleOffline' }));
socket.close();
};
const socket = getSocket();
socket.send(JSON.stringify({ action: 'toggleOnline' }));
socket.send(
JSON.stringify({
action: 'joinUser',
room: current?.id,
})
);
socket.addEventListener('message', (event) => {
const response: WSMessage = JSON.parse(event.data);
switch (response.action) {
case 'new_dm_notification': {
const channel = response.data;
if (channel.user.id !== current.id) {
cache.setQueryData([nKey], (data) => {
if (!data) return [{ ...channel, count: 1 }];
const index = data.findIndex((c) => c.id === channel.id);
// DM exists, increment message count
if (index !== -1 && index !== undefined) {
return [
{
...channel,
count: data[index].count + 1,
},
...data.filter((c) => c.id !== channel.id),
];
}
// Add the new DM to the list
return [
{
...channel,
count: 1,
},
...data,
];
});
}
break;
}
case 'send_request': {
if (!window.location.pathname.includes('/channels/me')) {
inc();
}
break;
}
default:
break;
}
});
window.addEventListener('beforeunload', disconnect);
return () => disconnect();
}
}, [current, inc, cache]);
/* eslint-disable-next-line react/jsx-no-useless-fragment */
return <>{children}>;
};
================================================
FILE: web/src/components/sections/Hero.tsx
================================================
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import { Link } from 'react-router-dom';
import { Box, Button, Flex, Heading, Image, Link as CLink, Stack, Text } from '@chakra-ui/react';
interface HeroProps {
title: string;
subtitle: string;
image: string;
ctaLink: string;
ctaText: string;
}
export const Hero: React.FC = ({ title, subtitle, image, ctaLink, ctaText, ...rest }) => (
{title}
{subtitle}
{ctaText}
Got an account already?{' '}
Sign in
);
================================================
FILE: web/src/components/sections/HomeIcon.tsx
================================================
import React, { useEffect, useState } from 'react';
import { Flex, useColorModeValue } from '@chakra-ui/react';
import { Link, useLocation } from 'react-router-dom';
import { StyledTooltip } from './StyledTooltip';
import { ActiveGuildPill, HoverGuildPill } from '../common/GuildPills';
import { homeStore } from '../../lib/stores/homeStore';
import { NotificationIcon } from '../common/NotificationIcon';
export const HomeIcon: React.FC = () => {
const location = useLocation();
const isActive = location.pathname === '/channels/me';
const [isHover, setHover] = useState(false);
const notification = homeStore((state) => state.notifCount);
const reset = homeStore((state) => state.reset);
useEffect(() => {
if (isActive) reset();
});
return (
{isActive && }
{isHover && }
setHover(false)}
onMouseEnter={() => setHover(true)}
>
{notification > 0 && }
);
};
const Logo: React.FC = () => {
const fill = useColorModeValue('#2D3748', '#fff');
return (
{/* eslint-disable-next-line max-len */}
{/* eslint-disable-next-line max-len */}
);
};
================================================
FILE: web/src/components/sections/NavBar.tsx
================================================
import { Button, Flex } from '@chakra-ui/react';
import React from 'react';
import { Link } from 'react-router-dom';
import { userStore } from '../../lib/stores/userStore';
import { Logo } from '../common/Logo';
export const NavBar: React.FC = () => {
const current = userStore((state) => state.current);
return (
{current ? (
Open App
) : (
<>
Login
Register
>
)}
);
};
================================================
FILE: web/src/components/sections/OnlineLabel.tsx
================================================
import React from 'react';
import { Text } from '@chakra-ui/react';
interface LabelProps {
label: string;
}
export const OnlineLabel: React.FC = ({ label }) => (
{label}
);
================================================
FILE: web/src/components/sections/StartMessages.tsx
================================================
import { Avatar, Box, Divider, Flex, Heading, Text } from '@chakra-ui/react';
import React from 'react';
import { useParams } from 'react-router-dom';
import { useGetCurrentChannel } from '../../lib/utils/hooks/useGetCurrentChannel';
import { useGetCurrentDM } from '../../lib/utils/hooks/useGetCurrentDM';
import { RouterProps } from '../../lib/models/routerProps';
export const StartMessages: React.FC = () => {
const { guildId } = useParams() as RouterProps;
return guildId === undefined ? : ;
};
const ChannelStartMessages: React.FC = () => {
const { guildId, channelId } = useParams() as RouterProps;
const channel = useGetCurrentChannel(channelId, guildId);
return (
Welcome to #{channel?.name}
This is the start of the #{channel?.name} channel
);
};
const DMStartMessages: React.FC = () => {
const { channelId } = useParams() as RouterProps;
const channel = useGetCurrentDM(channelId);
return (
{channel?.user.username}
This is the beginning of your direct message history with @{channel?.user.username}
);
};
================================================
FILE: web/src/components/sections/StyledTooltip.tsx
================================================
import React from 'react';
import { Tooltip } from '@chakra-ui/react';
type Placement = 'top' | 'right';
interface StyledTooltipProps {
label: string;
position: Placement;
disabled?: boolean;
children: React.ReactNode;
}
export const StyledTooltip: React.FC = ({ label, position, disabled = false, children }) => (
{children}
);
================================================
FILE: web/src/components/sections/UserPopover.tsx
================================================
import {
Avatar,
AvatarBadge,
Box,
Flex,
Popover,
PopoverContent,
PopoverFooter,
PopoverHeader,
PopoverTrigger,
Text,
} from '@chakra-ui/react';
import React from 'react';
import { Member } from '../../lib/models/member';
interface UserPopoverProps {
member: Member;
children: React.ReactNode;
}
export const UserPopover: React.FC = ({ member, children }) => (
{children}
{member.nickname ?? member.username}
{member.nickname && {member.username} }
Right click user for more actions
);
================================================
FILE: web/src/index.tsx
================================================
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import customTheme from './lib/utils/theme';
const container = document.getElementById('root')!;
const root = createRoot(container);
root.render(
);
================================================
FILE: web/src/lib/api/dtos/AuthInput.ts
================================================
// eslint-disable-next-line max-classes-per-file
export interface LoginDTO {
email: string;
password: string;
[key: string]: any;
}
export interface RegisterDTO extends LoginDTO {
username: string;
}
export class ChangePasswordInput {
currentPassword!: string;
newPassword!: string;
confirmNewPassword!: string;
}
export class ResetPasswordInput {
token!: string;
newPassword!: string;
confirmNewPassword!: string;
}
================================================
FILE: web/src/lib/api/dtos/ChannelInput.ts
================================================
export type ChannelInput = {
name: string;
isPublic: boolean;
members?: string[];
};
================================================
FILE: web/src/lib/api/dtos/GuildInput.ts
================================================
export type GuildInput = {
name: string;
image?: any;
};
================================================
FILE: web/src/lib/api/dtos/GuildMemberInput.ts
================================================
export type GuildMemberInput = {
nickname?: string;
color?: string;
};
================================================
FILE: web/src/lib/api/dtos/InviteInput.ts
================================================
export type InviteInput = {
link: string;
};
================================================
FILE: web/src/lib/api/dtos/UserInput.ts
================================================
export type UpdateInput = Partial<{
email: string;
image: any;
username: string;
}>;
================================================
FILE: web/src/lib/api/getSocket.ts
================================================
import ReconnectingWebSocket from 'reconnecting-websocket';
export const getSocket = (): ReconnectingWebSocket => new ReconnectingWebSocket(process.env.REACT_APP_WS!);
let socket: ReconnectingWebSocket | null = null;
export const getSameSocket = (): ReconnectingWebSocket => {
if (!socket) {
socket = new ReconnectingWebSocket(process.env.REACT_APP_WS!);
}
return socket;
};
================================================
FILE: web/src/lib/api/handler/account.ts
================================================
import { AxiosResponse } from 'axios';
import { request } from '../setupAxios';
import { Account } from '../../models/account';
import { Member } from '../../models/member';
import { FriendRequest } from '../../models/friend';
export const getAccount = (): Promise> => request.get('/account');
export const updateAccount = (body: FormData): Promise> =>
request.put('/account', body, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
export const getFriends = (): Promise> => request.get('/account/me/friends');
export const getPendingRequests = (): Promise> => request.get('/account/me/pending');
export const sendFriendRequest = (id: string): Promise> => request.post(`/account/${id}/friend`);
export const acceptFriendRequest = (id: string): Promise> =>
request.post(`/account/${id}/friend/accept`);
export const declineFriendRequest = (id: string): Promise> =>
request.post(`/account/${id}/friend/cancel`);
export const removeFriend = (id: string): Promise> => request.delete(`/account/${id}/friend`);
================================================
FILE: web/src/lib/api/handler/auth.ts
================================================
import { AxiosResponse } from 'axios';
import { request } from '../setupAxios';
import { ChangePasswordInput, LoginDTO, RegisterDTO, ResetPasswordInput } from '../dtos/AuthInput';
import { Account } from '../../models/account';
export const register = (body: RegisterDTO): Promise> => request.post('/account/register', body);
export const login = (body: LoginDTO): Promise> => request.post('/account/login', body);
export const logout = (): Promise => request.post('/account/logout');
export const forgotPassword = (email: string): Promise> =>
request.post('/account/forgot-password', { email });
export const changePassword = (body: ChangePasswordInput): Promise =>
request.put('/account/change-password', body);
export const resetPassword = (body: ResetPasswordInput): Promise> =>
request.post('/account/reset-password', body);
================================================
FILE: web/src/lib/api/handler/channel.ts
================================================
import { AxiosResponse } from 'axios';
import { request } from '../setupAxios';
import { ChannelInput } from '../dtos/ChannelInput';
import { Channel } from '../../models/channel';
export const getChannels = (id: string): Promise> => request.get(`channels/${id}`);
export const createChannel = (id: string, input: ChannelInput): Promise> =>
request.post(`channels/${id}`, input);
export const editChannel = (channelId: string, input: ChannelInput): Promise> =>
request.put(`channels/${channelId}`, input);
export const deleteChannel = (channelId: string): Promise> =>
request.delete(`channels/${channelId}`);
export const getPrivateChannelMembers = (channelId: string): Promise> =>
request.get(`channels/${channelId}/members`);
================================================
FILE: web/src/lib/api/handler/dm.ts
================================================
import { AxiosResponse } from 'axios';
import { request } from '../setupAxios';
import { DMChannel } from '../../models/dm';
export const getUserDMs = (): Promise> => request.get('/channels/me/dm');
export const getOrCreateDirectMessage = (id: string): Promise> =>
request.post(`/channels/${id}/dm`);
export const closeDirectMessage = (id: string): Promise> => request.delete(`/channels/${id}/dm`);
================================================
FILE: web/src/lib/api/handler/guilds.ts
================================================
import { AxiosResponse } from 'axios';
import { request } from '../setupAxios';
import { GuildInput } from '../dtos/GuildInput';
import { InviteInput } from '../dtos/InviteInput';
import { Guild } from '../../models/guild';
import { Member } from '../../models/member';
import { VCMember } from '../../models/voice';
export const getUserGuilds = (): Promise> => request.get('/guilds');
export const createGuild = (input: GuildInput): Promise> => request.post('guilds/create', input);
export const joinGuild = (input: InviteInput): Promise> => request.post('guilds/join', input);
export const getInviteLink = (id: string, isPermanent: boolean = false): Promise> =>
request.get(`guilds/${id}/invite${isPermanent ? '?isPermanent=true' : ''}`);
export const invalidateInviteLinks = (id: string): Promise> =>
request.delete(`guilds/${id}/invite`);
export const getGuildMembers = (id: string): Promise> => request.get(`guilds/${id}/members`);
export const getVCMembers = (id: string): Promise> => request.get(`guilds/${id}/vcmembers`);
export const leaveGuild = (id: string): Promise> => request.delete(`guilds/${id}`);
export const editGuild = (id: string, input: FormData): Promise> =>
request.put(`guilds/${id}`, input, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
export const deleteGuild = (id: string): Promise> => request.delete(`guilds/${id}/delete`);
================================================
FILE: web/src/lib/api/handler/members.ts
================================================
import { AxiosResponse } from 'axios';
import { GuildMemberInput } from '../dtos/GuildMemberInput';
import { request } from '../setupAxios';
import { Member } from '../../models/member';
export const getGuildMemberSettings = (id: string): Promise> =>
request.get(`guilds/${id}/member`);
export const changeGuildMemberSettings = (id: string, input: GuildMemberInput): Promise> =>
request.put(`guilds/${id}/member`, input);
export const getBanList = (id: string): Promise> => request.get(`guilds/${id}/bans`);
export const kickMember = (guildId: string, memberId: string): Promise> =>
request.post(`guilds/${guildId}/kick`, { memberId });
export const banMember = (guildId: string, memberId: string): Promise> =>
request.post(`guilds/${guildId}/bans`, { memberId });
export const unbanMember = (guildId: string, memberId: string): Promise> =>
request.delete(`guilds/${guildId}/bans`, { data: { memberId } });
================================================
FILE: web/src/lib/api/handler/messages.ts
================================================
import { AxiosResponse } from 'axios';
import { request } from '../setupAxios';
import { Message } from '../../models/message';
export const getMessages = (id: string, cursor?: string): Promise> =>
request.get(`messages/${id}${cursor ? `?cursor=${cursor}` : ''}`);
export const sendMessage = (
channelId: string,
data: FormData,
onUploadProgress?: (e: any) => void
): Promise> =>
request.post(`messages/${channelId}`, data, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress,
});
export const deleteMessage = (id: string): Promise> => request.delete(`messages/${id}`);
export const editMessage = (id: string, text: string): Promise> =>
request.put(`messages/${id}`, { text });
================================================
FILE: web/src/lib/api/setupAxios.ts
================================================
import Axios from 'axios';
export const request = Axios.create({
baseURL: `${process.env.REACT_APP_API!}/api`,
withCredentials: true,
});
================================================
FILE: web/src/lib/api/ws/useChannelSocket.ts
================================================
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { getSocket } from '../getSocket';
import { useGetCurrentGuild } from '../../utils/hooks/useGetCurrentGuild';
import { userStore } from '../../stores/userStore';
import { Channel } from '../../models/channel';
import { VCMember, VoiceResponse } from '../../models/voice';
import { cKey, vcKey } from '../../utils/querykeys';
type WSMessage =
| { action: 'delete_channel' | 'new_notification'; data: string }
| { action: 'add_channel' | 'add_private_channel' | 'edit_channel'; data: Channel }
| { action: 'joinVoice' | 'leaveVoice'; data: VoiceResponse };
export function useChannelSocket(guildId: string): void {
const location = useLocation();
const navigate = useNavigate();
const cache = useQueryClient();
const guild = useGetCurrentGuild(guildId);
const current = userStore((state) => state.current);
useEffect((): any => {
const socket = getSocket();
socket.send(
JSON.stringify({
action: 'joinGuild',
room: guildId,
})
);
socket.send(
JSON.stringify({
action: 'joinUser',
room: current?.id,
})
);
const disconnect = (): void => {
socket.send(
JSON.stringify({
action: 'leaveGuild',
room: guildId,
})
);
socket.send(
JSON.stringify({
action: 'leaveRoom',
room: current?.id,
})
);
socket.close();
};
socket.addEventListener('message', (event) => {
const response: WSMessage = JSON.parse(event.data);
switch (response.action) {
case 'add_channel': {
cache.setQueryData([cKey, guildId], (data) => [...(data ?? []), response.data]);
break;
}
case 'add_private_channel': {
cache.setQueryData([cKey, guildId], (data) => [...(data ?? []), response.data]);
break;
}
case 'edit_channel': {
const editedChannel = response.data;
cache.setQueryData([cKey, guildId], (d) => {
const data = d ?? [];
const contains = data.includes(editedChannel);
// Channel used to be private and is public now
if (!contains && editedChannel.isPublic) {
return [...data, editedChannel];
}
return data.map((c) => (c.id === editedChannel.id ? editedChannel : c));
});
break;
}
case 'delete_channel': {
const deleteId = response.data;
cache.setQueryData([cKey, guildId], (d) => {
const currentPath = `/channels/${guildId}/${deleteId}`;
// The deleted channel is the channel the user is currently in
if (location.pathname === currentPath && guild) {
// If it's the default channel, redirect to home
if (deleteId === guild.default_channel_id) {
navigate('/channels/me', { replace: true });
// Redirect the user to the default channel
} else {
navigate(`/channels/${guild.id}/${guild.default_channel_id}`, { replace: true });
}
}
return d?.filter((c) => c.id !== deleteId) ?? [];
});
break;
}
case 'new_notification': {
const id = response.data;
const currentPath = `/channels/${guildId}/${id}`;
if (location.pathname !== currentPath) {
cache.setQueryData(
[cKey, guildId],
(d) => d?.map((c) => (c.id === id ? { ...c, hasNotification: true } : c)) ?? []
);
}
break;
}
case 'joinVoice': {
const { data } = response;
// Remove the current user from the list
cache.setQueryData([vcKey, guildId], (_) => data.clients.filter((e) => e.id !== current?.id));
break;
}
case 'leaveVoice': {
const { data } = response;
// Remove the current user from the list
cache.setQueryData([vcKey, guildId], (_) => data.clients.filter((e) => e.id !== current?.id));
break;
}
default:
break;
}
});
window.addEventListener('beforeunload', disconnect);
return () => disconnect();
}, [guildId, cache, navigate, location, guild, current]);
}
================================================
FILE: web/src/lib/api/ws/useDMSocket.ts
================================================
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getSocket } from '../getSocket';
import { userStore } from '../../stores/userStore';
import { dmKey } from '../../utils/querykeys';
import { DMChannel } from '../../models/dm';
type WSMessage = { action: 'push_to_top'; data: string };
export function useDMSocket(): void {
const current = userStore((state) => state.current);
const cache = useQueryClient();
useEffect(() => {
const socket = getSocket();
socket.send(
JSON.stringify({
action: 'joinUser',
room: current?.id,
})
);
socket.addEventListener('message', (event) => {
const response: WSMessage = JSON.parse(event.data);
switch (response.action) {
case 'push_to_top': {
const dmId = response.data;
cache.setQueryData([dmKey], (d) => {
const data = d ?? [];
const index = data.findIndex((dm) => dm.id === dmId);
// If no DM exists or it's already the top one, do nothing
if (index === 0 || index === -1) return [...data];
// Push the DM to the top
const dm = data[index];
return [dm, ...data.filter((dc) => dc.id !== dmId)];
});
break;
}
default:
break;
}
});
return () => {
socket.send(
JSON.stringify({
action: 'leaveRoom',
room: current?.id,
})
);
socket.close();
};
}, [current, cache]);
}
================================================
FILE: web/src/lib/api/ws/useFriendSocket.ts
================================================
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getSocket } from '../getSocket';
import { userStore } from '../../stores/userStore';
import { fKey } from '../../utils/querykeys';
import { homeStore } from '../../stores/homeStore';
import { Friend } from '../../models/friend';
type WSMessage =
| { action: 'toggle_online' | 'toggle_offline' | 'remove_friend'; data: string }
| { action: 'requestCount'; data: number }
| { action: 'add_friend'; data: Friend };
export function useFriendSocket(): void {
const current = userStore((state) => state.current);
const setRequests = homeStore((state) => state.setRequests);
const cache = useQueryClient();
useEffect((): any => {
const socket = getSocket();
socket.send(
JSON.stringify({
action: 'joinUser',
room: current?.id,
})
);
socket.send(JSON.stringify({ action: 'getRequestCount' }));
socket.addEventListener('message', (event) => {
const response: WSMessage = JSON.parse(event.data);
switch (response.action) {
case 'toggle_online': {
cache.setQueryData([fKey], (d) => {
if (!d) return [];
return d.map((f) => (f.id === response.data ? { ...f, isOnline: true } : f));
});
break;
}
case 'toggle_offline': {
cache.setQueryData([fKey], (d) => {
if (!d) return [];
return d.map((f) => (f.id === response.data ? { ...f, isOnline: false } : f));
});
break;
}
case 'requestCount': {
setRequests(response.data);
break;
}
case 'add_friend': {
cache.setQueryData([fKey], (data) =>
[...(data ?? []), response.data].sort((a, b) => a.username.localeCompare(b.username))
);
break;
}
case 'remove_friend': {
cache.setQueryData([fKey], (data) => [...(data?.filter((m) => m.id !== response.data) ?? [])]);
break;
}
default:
break;
}
});
return () => {
socket.send(
JSON.stringify({
action: 'leaveRoom',
room: current?.id,
})
);
socket.close();
};
}, [cache, current, setRequests]);
}
================================================
FILE: web/src/lib/api/ws/useGuildSocket.ts
================================================
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { getSocket } from '../getSocket';
import { userStore } from '../../stores/userStore';
import { gKey } from '../../utils/querykeys';
import { Guild } from '../../models/guild';
type WSMessage =
| { action: 'delete_guild' | 'remove_from_guild' | 'new_notification'; data: string }
| { action: 'edit_guild'; data: Guild };
export function useGuildSocket(): void {
const navigate = useNavigate();
const cache = useQueryClient();
const current = userStore((state) => state.current);
const location = useLocation();
useEffect((): any => {
const socket = getSocket();
socket.send(
JSON.stringify({
action: 'joinUser',
room: current?.id,
})
);
socket.addEventListener('message', (event) => {
const response: WSMessage = JSON.parse(event.data);
switch (response.action) {
case 'edit_guild': {
const editedGuild = response.data;
cache.setQueryData([gKey], (d) => {
if (!d) return [];
return d.map((g) =>
g.id === editedGuild.id
? {
...g,
name: editedGuild.name,
icon: editedGuild.icon,
}
: g
);
});
break;
}
case 'delete_guild': {
const deleteId = response.data;
cache.setQueryData([gKey], (d) => {
const isActive = location.pathname.includes(deleteId);
if (isActive) {
navigate('/channels/me', { replace: true });
}
return d?.filter((g) => g.id !== deleteId) ?? [];
});
break;
}
case 'new_notification': {
const id = response.data;
if (!location.pathname.includes(id)) {
cache.setQueryData([gKey], (d) => {
if (!d) return [];
return d.map((g) =>
g.id === id
? {
...g,
hasNotification: true,
}
: g
);
});
}
break;
}
case 'remove_from_guild': {
cache.setQueryData([gKey], (d) => {
const guildId = response.data;
const isActive = location.pathname.includes(guildId);
if (isActive) {
navigate('/channels/me', { replace: true });
}
return d?.filter((g) => g.id !== guildId) ?? [];
});
break;
}
default:
break;
}
});
return () => {
socket.send(
JSON.stringify({
action: 'leaveRoom',
room: current?.id,
})
);
socket.close();
};
}, [current, cache, navigate, location]);
}
================================================
FILE: web/src/lib/api/ws/useMemberSocket.ts
================================================
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getSocket } from '../getSocket';
import { Member } from '../../models/member';
import { mKey } from '../../utils/querykeys';
type WSMessage =
| { action: 'remove_member' | 'toggle_online' | 'toggle_offline'; data: string }
| { action: 'add_member'; data: Member };
export function useMemberSocket(guildId: string): void {
const cache = useQueryClient();
useEffect((): any => {
const socket = getSocket();
const key = [mKey, guildId];
socket.send(
JSON.stringify({
action: 'joinGuild',
room: guildId,
})
);
socket.addEventListener('message', (event) => {
const response: WSMessage = JSON.parse(event.data);
switch (response.action) {
case 'add_member': {
cache.setQueryData(key, (data) =>
// Add member and sort array by nickname, then username
[...(data ?? []), response.data].sort((a, b) => {
if (a.nickname && b.nickname) {
return a.nickname.localeCompare(b.nickname);
}
if (a.nickname && !b.nickname) {
return a.nickname.localeCompare(b.username);
}
if (!a.nickname && b.nickname) {
return a.username.localeCompare(b.nickname);
}
return a.username.localeCompare(b.username);
})
);
break;
}
case 'remove_member': {
cache.setQueryData(key, (data) => [...(data?.filter((m) => m.id !== response.data) ?? [])]);
break;
}
case 'toggle_online': {
const memberId = response.data;
cache.setQueryData(key, (d) => {
if (!d) return [];
return d.map((m) => (m.id === memberId ? { ...m, isOnline: true } : m));
});
break;
}
case 'toggle_offline': {
const memberId = response.data;
cache.setQueryData(key, (d) => {
if (!d) return [];
return d.map((m) => (m.id === memberId ? { ...m, isOnline: false } : m));
});
break;
}
default:
break;
}
});
return () => {
socket.send(
JSON.stringify({
action: 'leaveRoom',
room: guildId,
})
);
socket.close();
};
}, [cache, guildId]);
}
================================================
FILE: web/src/lib/api/ws/useMessageSocket.ts
================================================
import { useEffect } from 'react';
import { InfiniteData, useQueryClient } from '@tanstack/react-query';
import { getSocket } from '../getSocket';
import { userStore } from '../../stores/userStore';
import { channelStore } from '../../stores/channelStore';
import { Message } from '../../models/message';
import { msgKey } from '../../utils/querykeys';
type WSMessage =
| { action: 'new_message' | 'edit_message'; data: Message }
| { action: 'addToTyping' | 'removeFromTyping' | 'delete_message'; data: string };
export function useMessageSocket(channelId: string): void {
const current = userStore((state) => state.current);
const store = channelStore();
const cache = useQueryClient();
useEffect((): any => {
store.reset();
const socket = getSocket();
socket.send(
JSON.stringify({
action: 'joinChannel',
room: channelId,
})
);
socket.addEventListener('message', (event) => {
const response: WSMessage = JSON.parse(event.data);
switch (response.action) {
case 'new_message': {
cache.setQueryData>([msgKey, channelId], (d) => {
if (!d) return { pages: [], pageParams: [] };
return {
pages: d.pages.map((messages, i) => (i === 0 ? [response.data, ...messages] : messages)),
pageParams: [...d.pageParams],
};
});
break;
}
case 'edit_message': {
const editMessage = response.data;
cache.setQueryData>([msgKey, channelId], (d) => {
if (!d) return { pages: [], pageParams: [] };
return {
pages: d.pages.map((messages) =>
messages.map((m) =>
m.id === editMessage.id ? { ...m, text: editMessage.text, updatedAt: editMessage.updatedAt } : m
)
),
pageParams: [...d.pageParams],
};
});
break;
}
case 'delete_message': {
const messageId = response.data;
cache.setQueryData>([msgKey, channelId], (d) => {
if (!d) return { pages: [], pageParams: [] };
return {
pages: d.pages.map((messages) => messages.filter((m) => m.id !== messageId)),
pageParams: [...d.pageParams],
};
});
break;
}
case 'addToTyping': {
const username = response.data;
if (username !== current?.username) store.addTyping(username);
break;
}
case 'removeFromTyping': {
const username = response.data;
if (username !== current?.username) store.removeTyping(username);
break;
}
default:
break;
}
});
return () => {
socket.send(
JSON.stringify({
action: 'leaveRoom',
room: channelId,
})
);
socket.close();
};
// eslint-disable-next-line
}, [channelId, cache, current]);
}
================================================
FILE: web/src/lib/api/ws/useRequestSocket.ts
================================================
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getSocket } from '../getSocket';
import { userStore } from '../../stores/userStore';
import { rKey } from '../../utils/querykeys';
import { homeStore } from '../../stores/homeStore';
import { FriendRequest } from '../../models/friend';
type WSMessage = { action: 'add_request'; data: FriendRequest };
export function useRequestSocket(): void {
const current = userStore((state) => state.current);
const setRequests = homeStore((state) => state.setRequests);
const cache = useQueryClient();
useEffect((): any => {
const socket = getSocket();
socket.send(
JSON.stringify({
action: 'joinUser',
room: current?.id,
})
);
socket.addEventListener('message', (event) => {
const response: WSMessage = JSON.parse(event.data);
switch (response.action) {
case 'add_request': {
cache.setQueryData([rKey], (data) =>
[...(data ?? []), response.data].sort((a, b) => a.username.localeCompare(b.username))
);
break;
}
default:
break;
}
});
return () => {
socket.send(
JSON.stringify({
action: 'leaveRoom',
room: current?.id,
})
);
socket.close();
};
}, [cache, current, setRequests]);
}
================================================
FILE: web/src/lib/api/ws/useVoiceSocket.ts
================================================
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { RouterProps } from '../../models/routerProps';
import { VCMember, VoiceResponse, VoiceSignal } from '../../models/voice';
import { userStore } from '../../stores/userStore';
import { voiceStore } from '../../stores/voiceStore';
import { getSameSocket } from '../getSocket';
import { vcKey } from '../../utils/querykeys';
type WSMessage =
| { action: 'joinVoice' | 'leaveVoice'; data: VoiceResponse }
| { action: 'toggle-mute' | 'toggle-deafen'; data: { id: string; value: boolean } }
| { action: 'voice-signal'; data: VoiceSignal };
export function useVoiceSocket(): void {
const { guildId } = useParams() as RouterProps;
const key = [vcKey, guildId];
const current = userStore((state) => state.current);
const [inVC, setVoiceClients, setVoiceJoinUserId, setRtcSignalData, setVoiceLeaveUserId] = voiceStore((state) => [
state.inVC,
state.setVoiceClients,
state.setVoiceJoinUserId,
state.setRtcSignalData,
state.setVoiceLeaveUserId,
]);
const cache = useQueryClient();
const socket = getSameSocket();
useEffect(() => {
if (inVC) {
socket.send(
JSON.stringify({
action: 'joinVoice',
room: guildId,
})
);
const disconnect = (): void => {
socket.send(
JSON.stringify({
action: 'leaveVoice',
room: guildId,
})
);
socket.close();
};
socket.addEventListener('message', (event) => {
const response: WSMessage = JSON.parse(event.data);
switch (response.action) {
case 'joinVoice': {
const { data } = response;
setVoiceClients(data.clients);
// Remove the current user from the list
cache.setQueryData(key, (_) => data.clients.filter((e) => e.id !== current?.id));
if (inVC) {
setVoiceJoinUserId(data.userId);
}
break;
}
case 'leaveVoice': {
const { data } = response;
setVoiceClients(data.clients);
// Remove the current user from the list
cache.setQueryData(key, (_) => data.clients.filter((e) => e.id !== current?.id));
if (inVC) {
setVoiceLeaveUserId(data.userId);
}
break;
}
case 'voice-signal': {
if (inVC) {
const { data } = response;
setRtcSignalData(data);
}
break;
}
// For unknown reasons voiceClients is empty
case 'toggle-mute': {
// const { data } = response;
// const clients = voiceClients.map((e) => {
// if (e.id === data.id) {
// return { ...e, isMuted: data.value };
// }
// return e;
// });
// setVoiceClients(clients);
// setVoiceMembers(clients);
break;
}
// Same here
case 'toggle-deafen': {
// const { data } = response;
// const clients = voiceClients.map((e) => {
// if (e.id === data.id) {
// return { ...e, isDeafened: data.value };
// }
// return e;
// });
// setVoiceClients(clients);
// setVoiceMembers(clients);
break;
}
default:
break;
}
});
window.addEventListener('beforeunload', disconnect);
return () => disconnect();
}
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inVC, socket]);
}
================================================
FILE: web/src/lib/models/account.ts
================================================
export interface Account {
id: string;
username: string;
email: string;
image: string;
}
================================================
FILE: web/src/lib/models/channel.ts
================================================
export interface Channel {
id: string;
name: string;
isPublic: boolean;
hasNotification?: boolean;
}
================================================
FILE: web/src/lib/models/dm.ts
================================================
export interface DMChannel {
id: string;
user: DMMember;
}
export interface DMNotification extends DMChannel {
count: number;
}
export interface DMMember {
id: string;
username: string;
image: string;
isOnline: boolean;
isFriend: boolean;
}
================================================
FILE: web/src/lib/models/fieldError.ts
================================================
export interface FieldError {
field: string;
message: string;
}
================================================
FILE: web/src/lib/models/friend.ts
================================================
export interface Friend {
id: string;
username: string;
image: string;
isOnline: boolean;
}
export enum RequestType {
OUTGOING,
INCOMING,
}
export interface FriendRequest {
id: string;
username: string;
image: string;
type: RequestType;
}
================================================
FILE: web/src/lib/models/guild.ts
================================================
export interface Guild {
id: string;
name: string;
ownerId: string;
default_channel_id: string;
icon?: string;
hasNotification?: boolean;
}
================================================
FILE: web/src/lib/models/member.ts
================================================
export interface Member {
id: string;
username: string;
image: string;
isOnline: boolean;
isFriend: boolean;
nickname?: string | null;
color?: string | null;
}
================================================
FILE: web/src/lib/models/message.ts
================================================
import { Member } from './member';
export interface Message {
id: string;
text?: string;
createdAt: string;
updatedAt: string;
attachment?: Attachment;
user: Member;
}
export interface Attachment {
filename: string;
filetype: string;
url: string;
}
================================================
FILE: web/src/lib/models/routerProps.ts
================================================
export type RouterProps = {
guildId: string;
channelId: string;
};
================================================
FILE: web/src/lib/models/voice.ts
================================================
export interface VoiceSignal {
userId: string;
sdp?: RTCSessionDescription | null;
ice?: RTCIceCandidate | null;
}
export interface VoiceResponse {
clients: VCMember[];
userId: string;
}
export interface VCMember {
id: string;
username: string;
image: string;
nickname?: string | null;
isMuted: boolean;
isDeafened: boolean;
stream?: MediaStream | null;
}
================================================
FILE: web/src/lib/stores/channelStore.ts
================================================
import create from 'zustand';
type ChannelState = {
typing: string[];
addTyping: (username: string) => void;
removeTyping: (username: string) => void;
reset: () => void;
};
export const channelStore = create((set) => ({
typing: [],
addTyping: (username) => set((state) => ({ typing: [...state.typing, username] })),
removeTyping: (username) => set((state) => ({ typing: [...state.typing.filter((u) => u !== username)] })),
reset: () => set({ typing: [] }),
}));
================================================
FILE: web/src/lib/stores/homeStore.ts
================================================
import create from 'zustand';
type HomeStoreType = {
notifCount: number;
requestCount: number;
increment: () => void;
setRequests: (r: number) => void;
reset: () => void;
resetRequest: () => void;
isPending: boolean;
toggleDisplay: () => void;
};
export const homeStore = create((set) => ({
notifCount: 0,
requestCount: 0,
increment: () => set((state) => ({ notifCount: state.notifCount + 1 })),
reset: () => set({ notifCount: 0 }),
resetRequest: () => set({ requestCount: 0 }),
setRequests: (r) => set({ requestCount: r }),
isPending: false,
toggleDisplay: () => set((state) => ({ isPending: !state.isPending })),
}));
================================================
FILE: web/src/lib/stores/settingsStore.ts
================================================
import create from 'zustand';
import { persist } from 'zustand/middleware';
type SettingsState = {
showMembers: boolean;
toggleShowMembers: () => void;
};
export const settingsStore = create(
persist(
(set, get) => ({
showMembers: true,
toggleShowMembers: () => set({ showMembers: !get().showMembers }),
}),
{
name: 'settings-storage',
}
)
);
================================================
FILE: web/src/lib/stores/userStore.ts
================================================
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { Account } from '../models/account';
type AccountState = {
current: Account | null;
setUser: (account: Account) => void;
logout: () => void;
};
export const userStore = create(
persist(
(set, _) => ({
current: null,
setUser: (account) => set({ current: account }),
logout: () => set({ current: null }),
}),
{
name: 'user-storage',
}
)
);
================================================
FILE: web/src/lib/stores/voiceStore.ts
================================================
import create from 'zustand';
import { VCMember, VoiceSignal } from '../models/voice';
type VoiceState = {
voiceChatID: string;
inVC: boolean;
voiceClients: VCMember[];
voiceJoinUserId: string;
voiceLeaveUserId: string;
localStream: MediaStream | null;
connections: Map;
rtcSignalData: VoiceSignal;
isMuted: boolean;
isDeafened: boolean;
setVoiceID: (id: string) => void;
setVoiceClients: (clients: VCMember[]) => void;
setInVC: (value: boolean) => void;
setVoiceJoinUserId: (id: string) => void;
setVoiceLeaveUserId: (id: string) => void;
setLocalStream: (stream: MediaStream) => void;
setRtcSignalData: (signal: VoiceSignal) => void;
getConnection: (userId: string) => RTCPeerConnection | undefined;
setConnection: (userId: string, connection: RTCPeerConnection) => void;
deleteConnection: (userId: string) => void;
clearConnections: () => void;
setIsMuted: (value: boolean) => void;
setIsDeafened: (value: boolean) => void;
leaveVoice: () => void;
};
export const voiceStore = create((set, get) => ({
voiceChatID: '',
voiceClients: [],
inVC: false,
voiceJoinUserId: '',
voiceLeaveUserId: '',
localStream: null,
connections: new Map(),
rtcSignalData: { userId: '' },
isMuted: false,
isDeafened: false,
setVoiceID: (id) => set({ voiceChatID: id }),
setInVC: (value) => set({ inVC: value }),
setVoiceClients: (clients) => set({ voiceClients: clients }),
setVoiceJoinUserId: (id) => set({ voiceJoinUserId: id }),
setVoiceLeaveUserId: (id) => set({ voiceLeaveUserId: id }),
setLocalStream: (stream) => set({ localStream: stream }),
setRtcSignalData: (signal) => set({ rtcSignalData: signal }),
getConnection: (userId) => get().connections.get(userId),
setConnection: (userId, connection) => get().connections.set(userId, connection),
deleteConnection: (userId) => get().connections.delete(userId),
clearConnections: () => {
get().connections.forEach((v, _) => {
v.close();
});
get().connections.clear();
},
setIsDeafened: (value) => {
const stream = get().localStream;
if (stream) stream.getAudioTracks()[0].enabled = !value;
set({ isDeafened: value, localStream: stream });
},
setIsMuted: (value) => {
const stream = get().localStream;
if (stream && !get().isDeafened) stream.getAudioTracks()[0].enabled = !value;
set({ isMuted: value, localStream: stream });
},
leaveVoice: () => {
get().clearConnections();
set({
inVC: false,
voiceClients: [],
voiceJoinUserId: '',
voiceLeaveUserId: '',
voiceChatID: '',
});
},
}));
================================================
FILE: web/src/lib/utils/cropImage.ts
================================================
const createImage = (url: string): Promise =>
new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener('load', () => resolve(image));
image.addEventListener('error', (error) => reject(error));
image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox
image.src = url;
});
/**
* This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
* @param {File} imageSrc - Image File url
* @param {Object} pixelCrop - pixelCrop Object provided by react-easy-crop
* @param {number} rotation - optional rotation parameter
*/
export default async function getCroppedImg(imageSrc: string, pixelCrop: any): Promise {
const image = await createImage(imageSrc);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const maxSize = Math.max(image.width, image.height);
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2));
// set each dimensions to double largest dimension to allow for a safe area for the
// image to rotate in without being clipped by canvas context
canvas.width = safeArea;
canvas.height = safeArea;
// translate canvas context to a central location on image to allow rotating around the center.
ctx!.translate(safeArea / 2, safeArea / 2);
ctx!.translate(-safeArea / 2, -safeArea / 2);
// draw rotated image and store data.
ctx!.drawImage(image, safeArea / 2 - image.width * 0.5, safeArea / 2 - image.height * 0.5);
const data = ctx!.getImageData(0, 0, safeArea, safeArea);
// set canvas width to final desired crop size - this will clear existing context
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
// paste generated rotate image with correct offsets for x,y crop values.
ctx!.putImageData(
data,
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
);
// As Base64 string
// return canvas.toDataURL('image/jpeg');
// As a blob
return new Promise((resolve) => {
canvas.toBlob((file) => {
resolve(file!);
}, 'image/jpeg');
});
}
================================================
FILE: web/src/lib/utils/dateUtils.ts
================================================
import dayjs from 'dayjs';
import calender from 'dayjs/plugin/calendar';
dayjs.extend(calender);
export const getTime = (createdAt: string): string => dayjs(createdAt).calendar();
export const getShortenedTime = (createdAt: string): string => dayjs(createdAt).format('h:mm A');
export const getTimeDifference = (date1: string, date2: string): number => dayjs(date1).diff(dayjs(date2), 'minutes');
export const checkNewDay = (date1: string, date2: string): boolean => !dayjs(date1).isSame(dayjs(date2), 'day');
export const formatDivider = (date: string): string => dayjs(date).format('MMMM D, YYYY');
================================================
FILE: web/src/lib/utils/hooks/useGetCurrentChannel.ts
================================================
import { useQuery } from '@tanstack/react-query';
import { Channel } from '../../models/channel';
import { cKey } from '../querykeys';
export function useGetCurrentChannel(channelId: string, guildId: string): Channel | undefined {
const { data } = useQuery([cKey, guildId]);
return data?.find((c) => c.id === channelId);
}
================================================
FILE: web/src/lib/utils/hooks/useGetCurrentDM.ts
================================================
import { useQuery } from '@tanstack/react-query';
import { dmKey } from '../querykeys';
import { DMChannel } from '../../models/dm';
export function useGetCurrentDM(channelId: string): DMChannel | undefined {
const { data } = useQuery([dmKey]);
return data?.find((c) => c.id === channelId);
}
================================================
FILE: web/src/lib/utils/hooks/useGetCurrentGuild.ts
================================================
import { useQuery } from '@tanstack/react-query';
import { gKey } from '../querykeys';
import { Guild } from '../../models/guild';
export function useGetCurrentGuild(guildId: string): Guild | undefined {
const { data: guildData } = useQuery([gKey]);
return guildData?.find((g) => g.id === guildId);
}
================================================
FILE: web/src/lib/utils/hooks/useGetFriend.ts
================================================
import { useQuery } from '@tanstack/react-query';
import { fKey } from '../querykeys';
import { Friend } from '../../models/friend';
export function useGetFriend(id: string): Friend | undefined {
const { data } = useQuery([fKey]);
return data?.find((f) => f.id === id);
}
================================================
FILE: web/src/lib/utils/hooks/useVoiceChat.ts
================================================
/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect } from 'react';
import { getSameSocket } from '../../api/getSocket';
import { VoiceSignal } from '../../models/voice';
import { userStore } from '../../stores/userStore';
import { voiceStore } from '../../stores/voiceStore';
export function useSetupVoiceChat(guildId: string): void {
const socket = getSameSocket();
const current = userStore((state) => state.current);
const voiceJoinUserId = voiceStore((state) => state.voiceJoinUserId);
const [voiceLeaveUserId, setVoiceLeaveUserId] = voiceStore((state) => [
state.voiceLeaveUserId,
state.setVoiceLeaveUserId,
]);
const [voiceClients, setVoiceClients] = voiceStore((state) => [state.voiceClients, state.setVoiceClients]);
const localStream = voiceStore((state) => state.localStream);
const rtcSignalData = voiceStore((state) => state.rtcSignalData);
const [getConnection, setConnection, deleteConnection] = voiceStore((state) => [
state.getConnection,
state.setConnection,
state.deleteConnection,
]);
const sendVoiceSignal = (message: VoiceSignal): void => {
socket.send(
JSON.stringify({
action: 'voice-signal',
room: guildId,
message: { ...message },
})
);
};
// On user join, add them to the connections array and bind event handlers + create offers
useEffect(() => {
const onUserJoin = async (): Promise => {
// Iterate over client list
voiceClients.forEach((user) => {
// If the new client is not in our list
if (!getConnection(user.id)) {
// Add this new users Peer connection to our connections map
setConnection(
user.id,
new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.services.mozilla.com' }, { urls: 'stun:stun.l.google.com:19302' }],
})
);
// Wait for peer to generate ice candidate
getConnection(user.id)!.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
if (event.candidate !== null) {
sendVoiceSignal({ userId: user.id, ice: event.candidate });
}
};
// Event handler for peer adding their stream
getConnection(user.id)!.ontrack = (event: RTCTrackEvent) => {
const clients = voiceClients.map((e) => {
if (e.id === user.id) {
return { ...e, stream: event.streams[0] };
}
return e;
});
setVoiceClients(clients);
};
// Adds our local audio stream to Peer
localStream?.getAudioTracks().forEach((track) => getConnection(user.id)!.addTrack(track, localStream!));
}
});
// Create offer to new client joining if it is not the current user
if (voiceJoinUserId !== current?.id) {
try {
const description = await getConnection(voiceJoinUserId)?.createOffer();
await getConnection(voiceJoinUserId)?.setLocalDescription(description);
sendVoiceSignal({ userId: voiceJoinUserId, sdp: getConnection(voiceJoinUserId)?.localDescription });
} catch (err) {}
}
};
if (voiceJoinUserId && voiceClients) {
onUserJoin();
}
}, [voiceJoinUserId, voiceClients]);
// New message from server, configure RTC sdp session objects
useEffect((): void => {
const onMessageFromServer = async (): Promise => {
const { userId, sdp, ice } = rtcSignalData;
// Check it's not coming from the current user
if (userId !== current?.id) {
if (sdp) {
try {
await getConnection(userId)!.setRemoteDescription(new RTCSessionDescription(sdp));
if (sdp?.type === 'offer') {
const description = await getConnection(userId)!.createAnswer();
// Improve audio quality
description.sdp = description.sdp?.replace(
'useinbandfec=1',
'useinbandfec=1; stereo=1; maxaveragebitrate=510000'
);
await getConnection(userId)!.setLocalDescription(description);
sendVoiceSignal({ userId, sdp: getConnection(userId)!.localDescription });
}
} catch (err) {}
}
if (ice) {
try {
await getConnection(userId)!.addIceCandidate(new RTCIceCandidate(ice));
} catch (err) {}
}
}
};
if (voiceJoinUserId !== '') {
onMessageFromServer();
}
}, [rtcSignalData, voiceJoinUserId]);
// If a user leaves, close the peer connection and remove them from the client list
useEffect((): void => {
if (voiceLeaveUserId !== '') {
// Close RTC peer connection
getConnection(voiceLeaveUserId)?.close();
deleteConnection(voiceLeaveUserId);
// Remove the audio element from page
const clients = voiceClients.filter((e) => e.id !== voiceLeaveUserId);
setVoiceClients(clients);
setVoiceLeaveUserId('');
}
}, [voiceLeaveUserId]);
}
================================================
FILE: web/src/lib/utils/querykeys.ts
================================================
export const gKey = 'guilds';
export const dmKey = 'dms';
export const aKey = 'account';
export const fKey = 'friends';
export const rKey = 'requests';
export const nKey = 'notification';
export const cKey = 'channels';
export const mKey = 'members';
export const msgKey = 'messages';
export const vcKey = 'voice';
================================================
FILE: web/src/lib/utils/theme.ts
================================================
import { extendTheme } from '@chakra-ui/react';
import { mode } from '@chakra-ui/theme-tools';
const config: any = {
initialColorMode: 'dark',
};
const styles = {
global: (props: any) => ({
body: {
bg: mode('gray.100', '#1b1c1d')(props),
},
}),
};
const colors = {
highlight: {
standard: '#7289da',
hover: '#677bc4',
active: '#5b6eae',
},
brandGray: {
accent: '#8e9297',
active: '#393c43',
light: '#36393f',
dark: '#303339',
darker: '#202225',
darkest: '#18191c',
hover: '#32353b',
},
brandGreen: '#43b581',
labelGray: '#72767d',
menuRed: '#f04747',
brandBorder: '#1A202C',
accountBar: '#292b2f',
memberList: '#2f3136',
iconColor: '#b9bbbe',
messageInput: '#40444b',
};
const fonts = {
body: "'Open Sans', sans-serif",
};
const customTheme = extendTheme({
colors,
config,
styles,
fonts,
});
export default customTheme;
export const scrollbarCss = {
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: '#2f3136',
width: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: 'brandGray.darker',
borderRadius: '18px',
},
};
================================================
FILE: web/src/lib/utils/toErrorMap.ts
================================================
import { FieldError } from '../models/fieldError';
export const toErrorMap = (errors: FieldError[]): Record => {
const errorMap: Record = {};
errors.forEach(({ field, message }) => {
errorMap[field.toLowerCase()] = message;
});
return errorMap;
};
================================================
FILE: web/src/lib/utils/validation/auth.schema.ts
================================================
import * as yup from 'yup';
export const LoginSchema = yup.object().shape({
email: yup.string().required('Email is required').defined(),
password: yup.string().required('Password is required').defined(),
});
export const RegisterSchema = yup.object().shape({
username: yup.string().min(3).max(30).trim().required('Username is required').defined(),
email: yup.string().email().lowercase().required('Email is required').defined(),
password: yup
.string()
.min(6, 'Password must be at least 6 characters long')
.max(150)
.required('Password is required')
.defined(),
});
export const UserSchema = yup.object().shape({
email: yup.string().email().lowercase().required('Email is required').defined(),
username: yup.string().min(3).max(30).trim().required('Username is required').defined(),
});
export const ResetPasswordSchema = yup.object().shape({
newPassword: yup
.string()
.min(6, 'Password must be at least 6 characters long')
.max(150)
.required('New Password is required')
.defined(),
confirmNewPassword: yup
.string()
.oneOf([yup.ref('newPassword'), undefined], 'Passwords do not match')
.required('Confirm New Password is required')
.defined(),
});
export const ChangePasswordSchema = yup.object().shape({
currentPassword: yup.string().required('Old Password is required').defined(),
newPassword: yup
.string()
.min(6, 'Password must be at least 6 characters long')
.max(150)
.required('New Password is required')
.defined(),
confirmNewPassword: yup
.string()
.oneOf([yup.ref('newPassword'), undefined], 'Passwords do not match')
.required('Confirm New Password is required')
.defined(),
});
export const ForgotPasswordSchema = yup.object().shape({
email: yup.string().email().lowercase().required('Email is required').defined(),
});
================================================
FILE: web/src/lib/utils/validation/channel.schema.ts
================================================
import * as yup from 'yup';
export const ChannelSchema = yup.object().shape({
name: yup.string().min(3).max(30).required('This field is required'),
isPublic: yup.boolean().optional().default(true),
members: yup.array(yup.string().optional().max(20, 'Must provide memberIds')).optional(),
});
================================================
FILE: web/src/lib/utils/validation/guild.schema.ts
================================================
import * as yup from 'yup';
export const GuildSchema = yup.object().shape({
name: yup.string().min(3).max(30).required(),
});
================================================
FILE: web/src/lib/utils/validation/member.schema.ts
================================================
import * as yup from 'yup';
export const MemberSchema = yup.object().shape({
nickname: yup.string().nullable().min(3).max(30),
color: yup
.string()
.nullable()
.matches(/^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i, 'The color must be a valid hex color'),
});
================================================
FILE: web/src/lib/utils/validation/message.schema.ts
================================================
import * as yup from 'yup';
const SUPPORTED_FORMATS = ['image/jpg', 'image/jpeg', 'audio/mp3', 'audio/mpeg', 'image/png'];
export const FileSchema = yup.object().shape({
file: yup
.mixed()
.nullable()
.test('count', 'Only one file is allowed', (value) => value?.length === 1)
.test('fileSize', 'The file is too large', (value) => !!value && value[0].size < 5000000)
.test(
'type',
'Only the following formats are accepted: Image and Audio',
(value) => !!value && SUPPORTED_FORMATS.includes(value[0].type)
),
});
================================================
FILE: web/src/react-app-env.d.ts
================================================
///
================================================
FILE: web/src/routes/AuthRoute.tsx
================================================
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import { Navigate } from 'react-router-dom';
import { userStore } from '../lib/stores/userStore';
interface IProps {
children: React.ReactNode;
}
export const AuthRoute: React.FC = ({ children }) => {
const storage = JSON.parse(localStorage.getItem('user-storage')!!);
const current = userStore((state) => state.current);
if (current || storage?.state?.current) {
return <>{children}>;
}
return ;
};
================================================
FILE: web/src/routes/ForgotPassword.tsx
================================================
import { Box, Button, Flex, Heading, Image, useToast } from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { InputField } from '../components/common/InputField';
import { toErrorMap } from '../lib/utils/toErrorMap';
import { ForgotPasswordSchema } from '../lib/utils/validation/auth.schema';
import { forgotPassword } from '../lib/api/handler/auth';
export const ForgotPassword: React.FC = () => {
const navigate = useNavigate();
const toast = useToast();
return (
Forgot Password
{
try {
const { data } = await forgotPassword(values.email);
if (data) {
toast({
title: 'Reset Mail.',
description: 'If an account with that email already exists, we sent you an email',
status: 'success',
duration: 5000,
isClosable: true,
});
navigate('/');
}
} catch (err: any) {
if (err?.response?.data?.errors) {
const errors = err?.response?.data?.errors;
setErrors(toErrorMap(errors));
}
}
}}
>
{({ isSubmitting }) => (
)}
);
};
================================================
FILE: web/src/routes/Home.tsx
================================================
import React from 'react';
import { useParams } from 'react-router-dom';
import { GuildList } from '../components/layouts/guild/GuildList';
import { DMSidebar } from '../components/layouts/home/DMSidebar';
import { FriendsDashboard } from '../components/layouts/home/dashboard/FriendsDashboard';
import { AppLayout } from '../components/layouts/AppLayout';
import { ChatScreen } from '../components/layouts/guild/chat/ChatScreen';
import { DMHeader } from '../components/layouts/home/DMHeader';
import { MessageInput } from '../components/layouts/guild/chat/MessageInput';
import { RouterProps } from '../lib/models/routerProps';
export const Home: React.FC = () => {
const { channelId } = useParams() as RouterProps;
return (
{channelId === undefined ? (
) : (
<>
>
)}
);
};
================================================
FILE: web/src/routes/Invite.tsx
================================================
import React, { useEffect, useState } from 'react';
import { Link as RLink, useNavigate, useParams } from 'react-router-dom';
import { Box, Flex, Image, Link, Text } from '@chakra-ui/react';
import { joinGuild } from '../lib/api/handler/guilds';
interface InviteRouter {
link: string;
}
export const Invite: React.FC = () => {
const { link } = useParams() as InviteRouter;
const [errors, setErrors] = useState(null);
const navigate = useNavigate();
useEffect(() => {
const handleJoin = async (): Promise => {
try {
const { data } = await joinGuild({ link });
if (data) {
navigate(`/channels/${data.id}/${data.default_channel_id}`, { replace: true });
}
} catch (err: any) {
const status = err?.response?.status;
if (status === 400 || status === 404 || status === 500) {
setErrors(err?.response?.data?.error?.message);
}
}
};
handleJoin();
}, [link, navigate]);
return (
Fetching server info. Please wait.
You will be automatically redirected.
{errors && (
{errors}
Click{' '}
here
{' '}
to return.
)}
);
};
================================================
FILE: web/src/routes/Landing.tsx
================================================
import React from 'react';
import { LandingLayout } from '../components/layouts/LandingLayout';
import { Hero } from '../components/sections/Hero';
export const Landing: React.FC = () => (
);
================================================
FILE: web/src/routes/Login.tsx
================================================
import { Box, Button, Flex, Heading, Image, Link, Text } from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import React from 'react';
import { Link as RLink, useNavigate } from 'react-router-dom';
import { InputField } from '../components/common/InputField';
import { toErrorMap } from '../lib/utils/toErrorMap';
import { userStore } from '../lib/stores/userStore';
import { LoginSchema } from '../lib/utils/validation/auth.schema';
import { login } from '../lib/api/handler/auth';
export const Login: React.FC = () => {
const navigate = useNavigate();
const setUser = userStore((state) => state.setUser);
return (
Welcome Back
{
try {
const { data } = await login(values);
if (data) {
setUser(data);
navigate('/channels/me');
}
} catch (err: any) {
if (err?.response?.status === 401) {
setErrors({ password: 'Invalid Credentials' });
}
if (err?.response?.data?.errors) {
const errors = err?.response?.data?.errors;
setErrors(toErrorMap(errors));
}
}
}}
>
{({ isSubmitting }) => (
)}
);
};
================================================
FILE: web/src/routes/Register.tsx
================================================
import React, { useState } from 'react';
import { Box, Button, Flex, Heading, Image, Link, Text } from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import { Link as RLink, useNavigate } from 'react-router-dom';
import { InputField } from '../components/common/InputField';
import { toErrorMap } from '../lib/utils/toErrorMap';
import { userStore } from '../lib/stores/userStore';
import { RegisterSchema } from '../lib/utils/validation/auth.schema';
import { register } from '../lib/api/handler/auth';
export const Register: React.FC = () => {
const navigate = useNavigate();
const setUser = userStore((state) => state.setUser);
const [error, showError] = useState(false);
return (
Welcome to Valkyrie
{
try {
const { data } = await register(values);
if (data) {
setUser(data);
navigate('/channels/me');
}
} catch (err: any) {
if (err?.response?.status === 500) {
showError(true);
}
if (err?.response?.data?.errors) {
const errors = err?.response?.data?.errors;
setErrors(toErrorMap(errors));
}
}
}}
>
{({ isSubmitting }) => (
)}
);
};
================================================
FILE: web/src/routes/ResetPassword.tsx
================================================
import { Box, Button, Flex, Heading, Image, Link, Text } from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import React, { useState } from 'react';
import { Link as RLink, useNavigate, useParams } from 'react-router-dom';
import { InputField } from '../components/common/InputField';
import { toErrorMap } from '../lib/utils/toErrorMap';
import { userStore } from '../lib/stores/userStore';
import { ResetPasswordSchema } from '../lib/utils/validation/auth.schema';
import { resetPassword } from '../lib/api/handler/auth';
type TokenProps = {
token: string;
};
export const ResetPassword: React.FC = () => {
const navigate = useNavigate();
const { token } = useParams() as TokenProps;
const [showError, setShowError] = useState(false);
const [tokenError, setTokenError] = useState('');
const setUser = userStore((state) => state.setUser);
return (
Reset Password
{
try {
const { data } = await resetPassword({
...values,
token,
});
if (data) {
setUser(data);
navigate('/channels/me');
}
} catch (err: any) {
if (err?.response?.status === 500) {
setShowError(true);
} else {
const errors = err?.response?.data?.errors;
const errorMap = toErrorMap(errors);
if ('token' in errorMap) {
setTokenError(errorMap.token);
}
setErrors(errorMap);
}
}
}}
>
{({ isSubmitting }) => (
)}
{showError && (
Server Error. Try again later
)}
{tokenError && (
Invalid or expired token.
Click here to get a new token
)}
);
};
================================================
FILE: web/src/routes/Routes.tsx
================================================
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { Login } from './Login';
import { Register } from './Register';
import { ForgotPassword } from './ForgotPassword';
import { ResetPassword } from './ResetPassword';
import { Home } from './Home';
import { ViewGuild } from './ViewGuild';
import { AuthRoute } from './AuthRoute';
import { Settings } from './Settings';
import { Landing } from './Landing';
import { Invite } from './Invite';
export const AppRoutes: React.FC = () => (
} />
} />
} />
} />
} />
}
/>
}
/>
}
/>
}
/>
}
/>
);
================================================
FILE: web/src/routes/Settings.tsx
================================================
import {
Avatar,
Box,
Button,
Divider,
Flex,
Heading,
LightMode,
Spacer,
Tooltip,
useDisclosure,
useToast,
} from '@chakra-ui/react';
import { Form, Formik } from 'formik';
import React, { useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { InputField } from '../components/common/InputField';
import { ChangePasswordModal } from '../components/modals/ChangePasswordModal';
import { toErrorMap } from '../lib/utils/toErrorMap';
import { userStore } from '../lib/stores/userStore';
import { UserSchema } from '../lib/utils/validation/auth.schema';
import { getAccount, updateAccount } from '../lib/api/handler/account';
import { logout } from '../lib/api/handler/auth';
import { CropImageModal } from '../components/modals/CropImageModal';
import { aKey } from '../lib/utils/querykeys';
import { Account } from '../lib/models/account';
export const Settings: React.FC = () => {
const navigate = useNavigate();
const toast = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: cropperIsOpen, onOpen: cropperOnOpen, onClose: cropperOnClose } = useDisclosure();
const { data: user } = useQuery([aKey], () => getAccount().then((response) => response.data));
const cache = useQueryClient();
const logoutUser = userStore((state) => state.logout);
const setUser = userStore((state) => state.setUser);
const inputFile: any = useRef(null);
const [imageUrl, setImageUrl] = useState(user?.image ?? null);
const [cropImage, setCropImage] = useState('');
const [croppedImage, setCroppedImage] = useState(null);
const closeClicked = (): void => {
navigate(-1);
};
const applyCrop = (file: Blob): void => {
setImageUrl(URL.createObjectURL(file));
setCroppedImage(new File([file], 'avatar', { type: 'image/jpeg' }));
cropperOnClose();
};
const logoutClicked = async (): Promise => {
const { data } = await logout();
if (data) {
cache.clear();
logoutUser();
navigate('/', { replace: true });
}
};
if (!user) return null;
return (
MY ACCOUNT
{
try {
const formData = new FormData();
formData.append('email', values.email);
formData.append('username', values.username);
if (croppedImage) {
formData.append('image', croppedImage);
}
const { data } = await updateAccount(formData);
if (data) {
setUser(data);
toast({
title: 'Account Updated.',
status: 'success',
duration: 3000,
isClosable: true,
});
}
} catch (err: any) {
if (err?.response?.status === 500) {
toast({
title: 'Server Error',
description: 'Try again later',
status: 'error',
duration: 3000,
isClosable: true,
});
}
if (err?.response?.data?.errors) {
const errors = err?.response?.data?.errors;
setErrors(toErrorMap(errors));
}
}
}}
>
{({ isSubmitting, values }) => (
)}
PASSWORD AND AUTHENTICATION
Change Password
Logout
{isOpen && }
{cropperIsOpen && (
)}
);
};
================================================
FILE: web/src/routes/ViewGuild.tsx
================================================
import React from 'react';
import { Channels } from '../components/layouts/guild/Channels';
import { GuildList } from '../components/layouts/guild/GuildList';
import { ChannelHeader } from '../components/layouts/guild/ChannelHeader';
import { MemberList } from '../components/layouts/guild/MemberList';
import { MessageInput } from '../components/layouts/guild/chat/MessageInput';
import { ChatScreen } from '../components/layouts/guild/chat/ChatScreen';
import { AppLayout } from '../components/layouts/AppLayout';
import { settingsStore } from '../lib/stores/settingsStore';
export const ViewGuild: React.FC = () => {
const showMemberList = settingsStore((state) => state.showMembers);
return (
{showMemberList && }
);
};
================================================
FILE: web/src/setupTests.ts
================================================
import '@testing-library/jest-dom';
import { setupServer } from 'msw/node';
import { handlers } from './tests/testUtils';
export const server = setupServer(...handlers);
// Establish API mocking before all tests.
beforeAll(() => server.listen());
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers());
// Clean up after the tests are finished.
afterAll(() => server.close());
================================================
FILE: web/src/tests/fixture/accountFixture.ts
================================================
import { Account } from '../../lib/models/account';
export const mockAccount: Account = {
id: '1444337838748340224',
username: 'Sen',
email: 'sen@example.com',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
};
================================================
FILE: web/src/tests/fixture/channelFixtures.ts
================================================
import { Channel } from '../../lib/models/channel';
export const mockChannel: Channel = {
id: '1444930025999568896',
name: 'general',
isPublic: true,
hasNotification: false,
};
export const mockChannelList = [mockChannel];
================================================
FILE: web/src/tests/fixture/dmFixtures.ts
================================================
import { DMChannel } from '../../lib/models/dm';
export const mockDMChannel: DMChannel = {
id: '1446384585456750592',
user: {
id: '1446384528997224448',
username: 'Alice',
image: 'https://gravatar.com/avatar/c160f8cc69a4f0bf2b0362752353d060?d=identicon',
isOnline: false,
isFriend: false,
},
};
export const mockDMChannelList = [mockDMChannel];
================================================
FILE: web/src/tests/fixture/friendFixture.ts
================================================
import { Friend } from '../../lib/models/friend';
export const mockFriend: Friend = {
id: '1446384528997224448',
username: 'Alice',
image: 'https://gravatar.com/avatar/c160f8cc69a4f0bf2b0362752353d060?d=identicon',
isOnline: false,
};
export const mockFriendList = [mockFriend];
================================================
FILE: web/src/tests/fixture/guildFixtures.ts
================================================
import { Guild } from '../../lib/models/guild';
export const mockGuild: Guild = {
id: '1444930025953431552',
name: "Sen's server",
ownerId: '1444337838748340224',
icon: undefined,
hasNotification: false,
default_channel_id: '1444930025999568896',
};
export const mockGuildList = [mockGuild];
================================================
FILE: web/src/tests/fixture/memberFixtures.ts
================================================
import { Member } from '../../lib/models/member';
export const mockMember: Member = {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: null,
color: null,
isFriend: false,
};
export const mockMemberList = [mockMember];
================================================
FILE: web/src/tests/fixture/messageFixtures.ts
================================================
import { Message } from '../../lib/models/message';
export const mockMessage: Message = {
id: '1444930038456651776',
text: 'Hello World',
createdAt: '2021-10-04T07:39:01.32804Z',
updatedAt: '2021-10-04T07:39:01.32804Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
};
export const mockMessageList: Message[] = [
{
id: '1446470552582623232',
text: '40',
createdAt: '2021-10-08T13:40:28.517656Z',
updatedAt: '2021-10-08T13:40:28.517656Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470544797995008',
text: '39',
createdAt: '2021-10-08T13:40:26.661389Z',
updatedAt: '2021-10-08T13:40:26.661389Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470535625052160',
text: '38',
createdAt: '2021-10-08T13:40:24.474183Z',
updatedAt: '2021-10-08T13:40:24.474183Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470519615393792',
text: '37',
createdAt: '2021-10-08T13:40:20.657062Z',
updatedAt: '2021-10-08T13:40:20.657062Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470497800818688',
text: '36',
createdAt: '2021-10-08T13:40:15.455995Z',
updatedAt: '2021-10-08T13:40:15.455995Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470480964882432',
text: '35',
createdAt: '2021-10-08T13:40:11.442021Z',
updatedAt: '2021-10-08T13:40:11.442021Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470477739462656',
text: '34',
createdAt: '2021-10-08T13:40:10.673121Z',
updatedAt: '2021-10-08T13:40:10.673121Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470472286867456',
text: '33',
createdAt: '2021-10-08T13:40:09.37347Z',
updatedAt: '2021-10-08T13:40:09.37347Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470469535404032',
text: '32',
createdAt: '2021-10-08T13:40:08.717298Z',
updatedAt: '2021-10-08T13:40:08.717298Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470463248142336',
text: '31',
createdAt: '2021-10-08T13:40:07.218367Z',
updatedAt: '2021-10-08T13:40:07.218367Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470454104559616',
text: '30',
createdAt: '2021-10-08T13:40:05.038445Z',
updatedAt: '2021-10-08T13:40:05.038445Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470449973170176',
text: '29',
createdAt: '2021-10-08T13:40:04.053672Z',
updatedAt: '2021-10-08T13:40:04.053672Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470443010625536',
text: '28',
createdAt: '2021-10-08T13:40:02.393155Z',
updatedAt: '2021-10-08T13:40:02.393155Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470436186492928',
text: '27',
createdAt: '2021-10-08T13:40:00.766696Z',
updatedAt: '2021-10-08T13:40:00.766696Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470429337194496',
text: '26',
createdAt: '2021-10-08T13:39:59.133462Z',
updatedAt: '2021-10-08T13:39:59.133462Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470426405376000',
text: '25',
createdAt: '2021-10-08T13:39:58.433884Z',
updatedAt: '2021-10-08T13:39:58.433884Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470419178590208',
text: '24',
createdAt: '2021-10-08T13:39:56.711199Z',
updatedAt: '2021-10-08T13:39:56.711199Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470417224044544',
text: '23',
createdAt: '2021-10-08T13:39:56.245516Z',
updatedAt: '2021-10-08T13:39:56.245516Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470414753599488',
text: '22',
createdAt: '2021-10-08T13:39:55.656156Z',
updatedAt: '2021-10-08T13:39:55.656156Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470411712729088',
text: '21',
createdAt: '2021-10-08T13:39:54.93168Z',
updatedAt: '2021-10-08T13:39:54.93168Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470408772521984',
text: '20',
createdAt: '2021-10-08T13:39:54.230331Z',
updatedAt: '2021-10-08T13:39:54.230331Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470405018619904',
text: '19',
createdAt: '2021-10-08T13:39:53.335287Z',
updatedAt: '2021-10-08T13:39:53.335287Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470400451022848',
text: '18',
createdAt: '2021-10-08T13:39:52.246774Z',
updatedAt: '2021-10-08T13:39:52.246774Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470395317194752',
text: '17',
createdAt: '2021-10-08T13:39:51.022541Z',
updatedAt: '2021-10-08T13:39:51.022541Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470388576948224',
text: '16',
createdAt: '2021-10-08T13:39:49.415463Z',
updatedAt: '2021-10-08T13:39:49.415463Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470385527689216',
text: '15',
createdAt: '2021-10-08T13:39:48.688824Z',
updatedAt: '2021-10-08T13:39:48.688824Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470383225016320',
text: '14',
createdAt: '2021-10-08T13:39:48.139847Z',
updatedAt: '2021-10-08T13:39:48.139847Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470380217700352',
text: '13',
createdAt: '2021-10-08T13:39:47.422735Z',
updatedAt: '2021-10-08T13:39:47.422735Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470374060462080',
text: '12',
createdAt: '2021-10-08T13:39:45.954377Z',
updatedAt: '2021-10-08T13:39:45.954377Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470370101039104',
text: '11',
createdAt: '2021-10-08T13:39:45.010823Z',
updatedAt: '2021-10-08T13:39:45.010823Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470366200336384',
text: '10',
createdAt: '2021-10-08T13:39:44.079948Z',
updatedAt: '2021-10-08T13:39:44.079948Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470357572653056',
text: '9',
createdAt: '2021-10-08T13:39:42.022909Z',
updatedAt: '2021-10-08T13:39:42.022909Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470356079480832',
text: '8',
createdAt: '2021-10-08T13:39:41.667508Z',
updatedAt: '2021-10-08T13:39:41.667508Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470354418536448',
text: '7',
createdAt: '2021-10-08T13:39:41.271009Z',
updatedAt: '2021-10-08T13:39:41.271009Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
{
id: '1446470352786952192',
text: '6',
createdAt: '2021-10-08T13:39:40.882862Z',
updatedAt: '2021-10-08T13:39:40.882862Z',
attachment: undefined,
user: {
id: '1444337838748340224',
username: 'Sen',
image: 'https://gravatar.com/avatar/b8abd16496a669abb69ad275a0b0103c?d=identicon',
isOnline: false,
nickname: undefined,
color: undefined,
isFriend: false,
},
},
];
================================================
FILE: web/src/tests/fixture/requestFixtures.ts
================================================
import { FriendRequest } from '../../lib/models/friend';
export const mockRequest: FriendRequest = {
id: '1446429666675003392',
username: 'John',
image: 'https://gravatar.com/avatar/d4c74594d841139328695756648b6bd6?d=identicon',
type: 0,
};
export const mockRequestList = [mockRequest];
================================================
FILE: web/src/tests/queries/account.test.tsx
================================================
import { renderHook } from '@testing-library/react-hooks';
import { useQuery } from '@tanstack/react-query';
import { rest } from 'msw';
import { aKey } from '../../lib/utils/querykeys';
import { createQueryClientWrapper } from '../testUtils';
import { server } from '../../setupTests';
import { getAccount } from '../../lib/api/handler/account';
import { mockAccount } from '../fixture/accountFixture';
describe('useQuery - getAccount', () => {
it("successfully fetches the user's info", async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useQuery([aKey], async () => {
const { data } = await getAccount();
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
expect(result.current.isFetching).toBe(true);
await waitForNextUpdate();
expect(result.current.isFetching).toBe(false);
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).not.toBeNull();
expect(result.current.data).not.toBeUndefined();
const account = result.current.data;
expect(account).toEqual(mockAccount);
expect(account?.id).toBeDefined();
expect(account?.username).toBeDefined();
expect(account?.email).toBeDefined();
expect(account?.image).toBeDefined();
});
it('returns an error when the server returns status 500', async () => {
server.use(rest.get('*', (req, res, ctx) => res(ctx.status(500))));
const { result, waitFor } = renderHook(
() =>
useQuery([aKey], async () => {
const { data } = await getAccount();
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
await waitFor(() => result.current.isError);
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeUndefined();
});
});
================================================
FILE: web/src/tests/queries/channel.test.tsx
================================================
import { renderHook } from '@testing-library/react-hooks';
import { QueryClientProvider, useQuery } from '@tanstack/react-query';
import { rest } from 'msw';
import * as React from 'react';
import { createQueryClientWrapper, createTestQueryClientWithData, IQueryWrapperProps } from '../testUtils';
import { cKey } from '../../lib/utils/querykeys';
import { server } from '../../setupTests';
import { mockGuild } from '../fixture/guildFixtures';
import { getChannels } from '../../lib/api/handler/channel';
import { mockChannel, mockChannelList } from '../fixture/channelFixtures';
import { useGetCurrentChannel } from '../../lib/utils/hooks/useGetCurrentChannel';
import { Channel } from '../../lib/models/channel';
describe('useQuery - getChannels', () => {
it("successfully fetches the current guild's channel list", async () => {
const guildId = '12312456127277383';
const { result, waitForNextUpdate } = renderHook(
() =>
useQuery([cKey, guildId], async () => {
const { data } = await getChannels(guildId);
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
expect(result.current.isFetching).toBe(true);
await waitForNextUpdate();
expect(result.current.isFetching).toBe(false);
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).not.toBeNull();
expect(result.current.data).not.toBeUndefined();
expect(result.current.data?.length).toEqual(1);
const channel = result.current.data?.[0];
expect(channel).toEqual(mockChannel);
expect(channel?.id).toBeDefined();
expect(channel?.name).toBeDefined();
expect(channel?.isPublic).toBeDefined();
expect(channel?.hasNotification).toBeFalsy();
});
it('returns an error when the server returns status 500', async () => {
const guildId = '12312456127277383';
server.use(rest.get('*', (req, res, ctx) => res(ctx.status(500))));
const { result, waitFor } = renderHook(
() =>
useQuery([cKey, guildId], async () => {
const { data } = await getChannels(guildId);
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
await waitFor(() => result.current.isError);
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeUndefined();
});
});
describe('useGetCurrentChannel', () => {
it('successfully fetches the channel for the given ID and key', async () => {
const channelId = mockChannel.id;
const key = [cKey, mockGuild.id];
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetCurrentChannel(channelId, mockGuild.id), {
wrapper,
});
try {
expect(result.current?.id).toEqual(channelId);
expect(result.current).toEqual(mockChannel);
} finally {
unmount();
}
});
it('returns undefined if it cannot find a channel with the given id', async () => {
const channelId = mockChannel.id;
const key = [cKey, mockGuild.id];
const channel: Channel = {
id: '12345676890345345',
name: 'Guild Name',
hasNotification: false,
isPublic: true,
};
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetCurrentChannel(channelId, mockGuild.id), {
wrapper,
});
try {
expect(result.current).toBeUndefined();
} finally {
unmount();
}
});
it('returns undefined if there is not initial data', async () => {
const channelId = mockChannel.id;
const key = [cKey, mockGuild.id];
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetCurrentChannel(channelId, mockGuild.id), {
wrapper,
});
try {
expect(result.current).toBeUndefined();
} finally {
unmount();
}
});
});
================================================
FILE: web/src/tests/queries/dm.test.tsx
================================================
import { renderHook } from '@testing-library/react-hooks';
import { QueryClientProvider, useQuery } from '@tanstack/react-query';
import { rest } from 'msw';
import * as React from 'react';
import { dmKey } from '../../lib/utils/querykeys';
import { createQueryClientWrapper, createTestQueryClientWithData, IQueryWrapperProps } from '../testUtils';
import { server } from '../../setupTests';
import { getUserDMs } from '../../lib/api/handler/dm';
import { mockDMChannel, mockDMChannelList } from '../fixture/dmFixtures';
import { useGetCurrentDM } from '../../lib/utils/hooks/useGetCurrentDM';
import { DMChannel } from '../../lib/models/dm';
describe('useQuery - getUserDMs', () => {
it("successfully fetches the user's dm list", async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useQuery([dmKey], async () => {
const { data } = await getUserDMs();
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
expect(result.current.isFetching).toBe(true);
await waitForNextUpdate();
expect(result.current.isFetching).toBe(false);
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).not.toBeNull();
expect(result.current.data).not.toBeUndefined();
expect(result.current.data?.length).toEqual(1);
const dm = result.current.data?.[0];
expect(dm).toEqual(mockDMChannel);
expect(dm?.id).toBeDefined();
expect(dm?.user).toBeDefined();
const user = dm?.user;
expect(user?.id).toBeDefined();
expect(user?.image).toBeDefined();
expect(user?.username).toBeDefined();
expect(user?.isOnline).toBeDefined();
});
it('returns an error when the server returns status 500', async () => {
server.use(rest.get('*', (req, res, ctx) => res(ctx.status(500))));
const { result, waitFor } = renderHook(
() =>
useQuery([dmKey], async () => {
const { data } = await getUserDMs();
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
await waitFor(() => result.current.isError);
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeUndefined();
});
});
describe('useGetCurrentDM', () => {
it('successfully fetches the dm for the given ID', async () => {
const channelId = mockDMChannel.id;
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetCurrentDM(channelId), {
wrapper,
});
try {
expect(result.current?.id).toEqual(channelId);
expect(result.current).toEqual(mockDMChannel);
} finally {
unmount();
}
});
it('returns undefined if it cannot find a dm with the given id', async () => {
const channelId = mockDMChannel.id;
const dmChannel: DMChannel = {
id: '12345676890345345',
user: {
image: '',
isOnline: true,
isFriend: false,
username: 'Test User',
id: '123941059157915',
},
};
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetCurrentDM(channelId), {
wrapper,
});
try {
expect(result.current).toBeUndefined();
} finally {
unmount();
}
});
it('returns undefined if there is not initial data', async () => {
const channelId = mockDMChannel.id;
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetCurrentDM(channelId), {
wrapper,
});
try {
expect(result.current).toBeUndefined();
} finally {
unmount();
}
});
});
================================================
FILE: web/src/tests/queries/friend.test.tsx
================================================
import { renderHook } from '@testing-library/react-hooks';
import { QueryClientProvider, useQuery } from '@tanstack/react-query';
import { rest } from 'msw';
import * as React from 'react';
import { createQueryClientWrapper, createTestQueryClientWithData, IQueryWrapperProps } from '../testUtils';
import { fKey } from '../../lib/utils/querykeys';
import { server } from '../../setupTests';
import { getFriends } from '../../lib/api/handler/account';
import { mockFriend, mockFriendList } from '../fixture/friendFixture';
import { useGetFriend } from '../../lib/utils/hooks/useGetFriend';
describe('useQuery - getFriends', () => {
it("successfully fetches the user's friend list", async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useQuery([fKey], async () => {
const { data } = await getFriends();
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
expect(result.current.isFetching).toBe(true);
await waitForNextUpdate();
expect(result.current.isFetching).toBe(false);
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).not.toBeNull();
expect(result.current.data).not.toBeUndefined();
expect(result.current.data?.length).toEqual(1);
const friend = result.current.data?.[0];
expect(friend).toEqual(mockFriend);
expect(friend?.id).toBeDefined();
expect(friend?.username).toBeDefined();
expect(friend?.image).toBeDefined();
expect(friend?.isOnline).toBeDefined();
});
it('returns an error when the server returns status 500', async () => {
server.use(rest.get('*', (req, res, ctx) => res(ctx.status(500))));
const { result, waitFor } = renderHook(
() =>
useQuery([fKey], async () => {
const { data } = await getFriends();
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
await waitFor(() => result.current.isError);
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeUndefined();
});
});
describe('useGetFriend', () => {
it('successfully fetches the friend for the given ID', async () => {
const friendId = mockFriend.id;
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetFriend(friendId), {
wrapper,
});
try {
expect(result.current?.id).toEqual(friendId);
expect(result.current).toEqual(mockFriend);
} finally {
unmount();
}
});
it('returns undefined if it cannot find a friend with the given id', async () => {
const friendId = mockFriend.id;
const friend = {
id: '12345676890345345',
username: 'Test User',
image: 'https://gravatar.com/avatar/c160f8cc69a4f0bf2b0362752353d060?d=identicon',
isOnline: false,
};
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetFriend(friendId), {
wrapper,
});
try {
expect(result.current).toBeUndefined();
} finally {
unmount();
}
});
it('returns undefined if there is not initial data', async () => {
const friendId = mockFriend.id;
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetFriend(friendId), {
wrapper,
});
try {
expect(result.current).toBeUndefined();
} finally {
unmount();
}
});
});
================================================
FILE: web/src/tests/queries/guild.test.tsx
================================================
import { renderHook } from '@testing-library/react-hooks';
import { QueryClientProvider, useQuery } from '@tanstack/react-query';
import { rest } from 'msw';
import * as React from 'react';
import { createQueryClientWrapper, createTestQueryClientWithData, IQueryWrapperProps } from '../testUtils';
import { gKey } from '../../lib/utils/querykeys';
import { getUserGuilds } from '../../lib/api/handler/guilds';
import { server } from '../../setupTests';
import { useGetCurrentGuild } from '../../lib/utils/hooks/useGetCurrentGuild';
import { mockGuild, mockGuildList } from '../fixture/guildFixtures';
import { Guild } from '../../lib/models/guild';
describe('useQuery - getUserGuilds', () => {
it("successfully fetches the user's guild list", async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useQuery([gKey], async () => {
const { data } = await getUserGuilds();
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
expect(result.current.isFetching).toBe(true);
await waitForNextUpdate();
expect(result.current.isFetching).toBe(false);
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).not.toBeNull();
expect(result.current.data).not.toBeUndefined();
expect(result.current.data?.length).toEqual(1);
const guild = result.current.data?.[0];
expect(guild).toEqual(mockGuild);
expect(guild?.id).toBeDefined();
expect(guild?.name).toBeDefined();
expect(guild?.ownerId).toBeDefined();
expect(guild?.hasNotification).toBeFalsy();
expect(guild?.default_channel_id).toBeDefined();
});
it('returns an error when the server returns status 500', async () => {
server.use(rest.get('*', (req, res, ctx) => res(ctx.status(500))));
const { result, waitFor } = renderHook(
() =>
useQuery([gKey], async () => {
const { data } = await getUserGuilds();
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
await waitFor(() => result.current.isError);
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeUndefined();
});
});
describe('useGetCurrentGuild', () => {
it('successfully fetches the guild for the given ID', async () => {
const guildId = mockGuild.id;
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetCurrentGuild(guildId), {
wrapper,
});
try {
expect(result.current?.id).toEqual(guildId);
} finally {
unmount();
}
});
it('returns undefined if it cannot find a guild with the given id', async () => {
const guildId = mockGuild.id;
const guild: Guild = {
id: '12345676890345345',
name: 'Guild Name',
default_channel_id: '149587609049385',
ownerId: '123941059157915',
};
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetCurrentGuild(guildId), {
wrapper,
});
try {
expect(result.current).toBeUndefined();
} finally {
unmount();
}
});
it('returns undefined if there is not initial data', async () => {
const guildId = mockGuild.id;
const wrapper: React.FC = ({ children }) => (
{children}
);
const { result, unmount } = renderHook(() => useGetCurrentGuild(guildId), {
wrapper,
});
try {
expect(result.current).toBeUndefined();
} finally {
unmount();
}
});
});
================================================
FILE: web/src/tests/queries/member.test.tsx
================================================
import { renderHook } from '@testing-library/react-hooks';
import { useQuery } from '@tanstack/react-query';
import { rest } from 'msw';
import { mKey } from '../../lib/utils/querykeys';
import { createQueryClientWrapper } from '../testUtils';
import { server } from '../../setupTests';
import { getGuildMembers } from '../../lib/api/handler/guilds';
import { mockGuild } from '../fixture/guildFixtures';
import { mockMember } from '../fixture/memberFixtures';
describe('useQuery - getGuildMembers', () => {
it("successfully fetches the guild's member list", async () => {
const guildId = mockGuild.id;
const { result, waitForNextUpdate } = renderHook(
() =>
useQuery([mKey, guildId], async () => {
const { data } = await getGuildMembers(guildId);
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
expect(result.current.isFetching).toBe(true);
await waitForNextUpdate();
expect(result.current.isFetching).toBe(false);
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).not.toBeNull();
expect(result.current.data).not.toBeUndefined();
const member = result.current.data?.[0];
expect(member).toEqual(mockMember);
expect(member?.id).toBeDefined();
expect(member?.username).toBeDefined();
expect(member?.image).toBeDefined();
expect(member?.isOnline).toBeDefined();
expect(member?.isFriend).toBeDefined();
});
it('returns an error when the server returns status 500', async () => {
const guildId = mockGuild.id;
server.use(rest.get('*', (req, res, ctx) => res(ctx.status(500))));
const { result, waitFor } = renderHook(
() =>
useQuery([mKey, guildId], async () => {
const { data } = await getGuildMembers(guildId);
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
await waitFor(() => result.current.isError);
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeUndefined();
});
});
================================================
FILE: web/src/tests/queries/message.test.tsx
================================================
import { renderHook } from '@testing-library/react-hooks';
import { useInfiniteQuery } from '@tanstack/react-query';
import { rest } from 'msw';
import { createQueryClientWrapper } from '../testUtils';
import { server } from '../../setupTests';
import { getMessages } from '../../lib/api/handler/messages';
import { mockChannel } from '../fixture/channelFixtures';
import { mockMessageList } from '../fixture/messageFixtures';
import { Message } from '../../lib/models/message';
describe('useQuery - getMessages', () => {
it("successfully fetches the channel's message list", async () => {
const channelId = mockChannel.id;
const qKey = 'messages';
const { result, waitForNextUpdate } = renderHook(
() =>
useInfiniteQuery(
[qKey, channelId],
async ({ pageParam = null }) => {
const { data: messageData } = await getMessages(channelId, pageParam);
return messageData;
},
{
staleTime: 0,
cacheTime: 0,
getNextPageParam: (lastPage) => (lastPage.length ? lastPage[lastPage.length - 1].createdAt : ''),
}
),
{
wrapper: createQueryClientWrapper,
}
);
expect(result.current.isFetching).toBe(true);
await waitForNextUpdate();
expect(result.current.isFetching).toBe(false);
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).not.toBeNull();
expect(result.current.data).not.toBeUndefined();
expect(result.current.data).toEqual({ pageParams: [undefined], pages: [mockMessageList] });
});
it('returns an error when the server returns status 500', async () => {
const channelId = mockChannel.id;
const qKey = 'messages';
server.use(rest.get('*', (req, res, ctx) => res(ctx.status(500))));
const { result, waitFor } = renderHook(
() =>
useInfiniteQuery(
[qKey, channelId],
async ({ pageParam = null }) => {
const { data: messageData } = await getMessages(channelId, pageParam);
return messageData;
},
{
staleTime: 0,
cacheTime: 0,
getNextPageParam: (lastPage) => (lastPage.length ? lastPage[lastPage.length - 1].createdAt : ''),
}
),
{
wrapper: createQueryClientWrapper,
}
);
await waitFor(() => result.current.isError);
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeUndefined();
});
});
================================================
FILE: web/src/tests/queries/request.test.tsx
================================================
import { renderHook } from '@testing-library/react-hooks';
import { useQuery } from '@tanstack/react-query';
import { rest } from 'msw';
import { rKey } from '../../lib/utils/querykeys';
import { createQueryClientWrapper } from '../testUtils';
import { server } from '../../setupTests';
import { getPendingRequests } from '../../lib/api/handler/account';
import { mockRequest } from '../fixture/requestFixtures';
describe('useQuery - getPendingRequests', () => {
it("successfully fetches the user's pending requests", async () => {
const { result, waitForNextUpdate } = renderHook(
() =>
useQuery([rKey], async () => {
const { data } = await getPendingRequests();
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
expect(result.current.isFetching).toBe(true);
await waitForNextUpdate();
expect(result.current.isFetching).toBe(false);
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).not.toBeNull();
expect(result.current.data).not.toBeUndefined();
const request = result.current.data?.[0];
expect(request).toEqual(mockRequest);
expect(request?.id).toBeDefined();
expect(request?.username).toBeDefined();
expect(request?.image).toBeDefined();
expect(request?.type).toBeDefined();
});
it('returns an error when the server returns status 500', async () => {
server.use(rest.get('*', (req, res, ctx) => res(ctx.status(500))));
const { result, waitFor } = renderHook(
() =>
useQuery([rKey], async () => {
const { data } = await getPendingRequests();
return data;
}),
{
wrapper: createQueryClientWrapper,
}
);
await waitFor(() => result.current.isError);
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeUndefined();
});
});
================================================
FILE: web/src/tests/testUtils.tsx
================================================
import { rest } from 'msw';
import * as React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { mockGuildList } from './fixture/guildFixtures';
import { mockDMChannelList } from './fixture/dmFixtures';
import { mockAccount } from './fixture/accountFixture';
import { mockFriendList } from './fixture/friendFixture';
import { mockRequestList } from './fixture/requestFixtures';
import { mockChannelList } from './fixture/channelFixtures';
import { mockMemberList } from './fixture/memberFixtures';
import { mockMessageList } from './fixture/messageFixtures';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
logger: {
// eslint-disable-next-line no-console
log: console.log,
// eslint-disable-next-line no-console
warn: console.warn,
error: () => {},
},
});
export const createTestQueryClientWithData = (key: string[], data: any[]): QueryClient => {
const client = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
logger: {
// eslint-disable-next-line no-console
log: console.log,
// eslint-disable-next-line no-console
warn: console.warn,
error: () => {},
},
});
client.setQueryData(key, () => [...data]);
return client;
};
export interface IQueryWrapperProps {
children: React.ReactNode;
}
export const createQueryClientWrapper: React.FC = ({ children }) => (
{children}
);
export const handlers = [
rest.get('*/guilds', (req, res, ctx) => res(ctx.status(200), ctx.json(mockGuildList))),
rest.get('*/channels/me/dm', (req, res, ctx) => res(ctx.status(200), ctx.json(mockDMChannelList))),
rest.get('*/account', (req, res, ctx) => res(ctx.status(200), ctx.json(mockAccount))),
rest.get('*/account/me/friends', (req, res, ctx) => res(ctx.status(200), ctx.json(mockFriendList))),
rest.get('*/account/me/pending', (req, res, ctx) => res(ctx.status(200), ctx.json(mockRequestList))),
rest.get('*/channels/*', (req, res, ctx) => res(ctx.status(200), ctx.json(mockChannelList))),
rest.get('*/guilds/*/members', (req, res, ctx) => res(ctx.status(200), ctx.json(mockMemberList))),
rest.get('*/messages/*', (req, res, ctx) => res(ctx.status(200), ctx.json(mockMessageList))),
];
================================================
FILE: web/tsconfig.json
================================================
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}