diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 74ebd1c43..2e6986722 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -2,8 +2,58 @@ name: ocelot.social end-to-end test CI on: push jobs: + docker_preparation: + name: Fullstack test preparation + runs-on: ubuntu-latest + outputs: + pr-number: ${{ steps.pr.outputs.number }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Copy env files + run: | + cp webapp/.env.template webapp/.env + cp backend/.env.template backend/.env + + - name: Build docker images + run: | + mkdir /tmp/images + docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/ + docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/images/neo4j.tar + docker build --target test -t "ocelotsocialnetwork/backend:test" backend/ + docker save "ocelotsocialnetwork/backend:test" > /tmp/images/backend.tar + docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/ + docker save "ocelotsocialnetwork/webapp:test" > /tmp/images/webapp.tar + + - name: Install cypress requirements + run: | + wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386" + cd backend + yarn install + yarn build + cd .. + yarn install + + - name: Get pr number + id: pr + uses: 8BitJonny/gh-get-current-pr@2.2.0 + + - name: Cache docker images + id: cache + uses: actions/cache/save@v3.3.1 + with: + path: | + /opt/cucumber-json-formatter + /home/runner/.cache/Cypress + /home/runner/work/Ocelot-Social/Ocelot-Social + /tmp/images/ + key: e2e-preparation-cache-pr${{ steps.pr.outputs.number }} + fullstack_tests: name: Fullstack tests + if: success() + needs: docker_preparation runs-on: ubuntu-latest env: jobs: 8 @@ -12,28 +62,27 @@ jobs: # run copies of the current job in parallel job: [1, 2, 3, 4, 5, 6, 7, 8] steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Restore cache + uses: actions/cache/restore@v3.3.1 + id: cache + with: + path: | + /opt/cucumber-json-formatter + /home/runner/.cache/Cypress + /home/runner/work/Ocelot-Social/Ocelot-Social + /tmp/images/ + key: e2e-preparation-cache-pr${{ needs.docker_preparation.outputs.pr-number }} + fail-on-cache-miss: true - - name: webapp | copy env file - run: cp webapp/.env.template webapp/.env - - - name: backend | copy env file - run: cp backend/.env.template backend/.env - - - name: boot up test system | docker-compose - run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend - - - name: Full stack tests | prepare + - name: Boot up test system | docker-compose run: | - wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386" chmod +x /opt/cucumber-json-formatter sudo ln -fs /opt/cucumber-json-formatter /usr/bin/cucumber-json-formatter - cd backend - yarn install - yarn build - cd .. - yarn install + docker load < /tmp/images/neo4j.tar + docker load < /tmp/images/backend.tar + docker load < /tmp/images/webapp.tar + docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp neo4j backend + sleep 90s - name: Full stack tests | run tests id: e2e-tests @@ -44,17 +93,25 @@ jobs: run: | cd cypress/ node create-cucumber-html-report.js - - - name: End-to-end tests | if tests failed, get pr number - id: pr - if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }} - uses: 8BitJonny/gh-get-current-pr@2.2.0 - - name: End-to-end tests | if tests failed, upload report + - name: Full stack tests | if tests failed, upload report id: e2e-report if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }} uses: actions/upload-artifact@v3 with: - name: ocelot-e2e-test-report-pr${{ steps.pr.outputs.number }} + name: ocelot-e2e-test-report-pr${{ needs.docker_preparation.outputs.pr-number }} path: /home/runner/work/Ocelot-Social/Ocelot-Social/cypress/reports/cucumber_html_report + cleanup: + name: Cleanup + if: always() + needs: [docker_preparation, fullstack_tests] + runs-on: ubuntu-latest + steps: + - name: Delete cache + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh extension install actions/gh-actions-cache + KEY="e2e-preparation-cache-pr${{ needs.docker_preparation.outputs.pr-number }}" + gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm \ No newline at end of file diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts index a717ff7a6..7286683dd 100644 --- a/backend/src/db/seed.ts +++ b/backend/src/db/seed.ts @@ -11,6 +11,8 @@ import { changeGroupMemberRoleMutation, } from '../graphql/groups' import { createPostMutation } from '../graphql/posts' +import { createRoomMutation } from '../graphql/rooms' +import { createMessageMutation } from '../graphql/messages' import { createCommentMutation } from '../graphql/comments' import { categories } from '../constants/categories' @@ -1553,6 +1555,90 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] ) await Factory.build('donations') + + // Chat + authenticatedUser = await huey.toJson() + const { data: roomHueyPeter } = await mutate({ + mutation: createRoomMutation(), + variables: { + userId: (await peterLustig.toJson()).id, + }, + }) + + for (let i = 0; i < 30; i++) { + authenticatedUser = await huey.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: roomHueyPeter?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + authenticatedUser = await peterLustig.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: roomHueyPeter?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + } + + authenticatedUser = await huey.toJson() + const { data: roomHueyJenny } = await mutate({ + mutation: createRoomMutation(), + variables: { + userId: (await jennyRostock.toJson()).id, + }, + }) + for (let i = 0; i < 1000; i++) { + authenticatedUser = await huey.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: roomHueyJenny?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + authenticatedUser = await jennyRostock.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: roomHueyJenny?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + } + + for (const user of additionalUsers) { + authenticatedUser = await jennyRostock.toJson() + const { data: room } = await mutate({ + mutation: createRoomMutation(), + variables: { + userId: (await user.toJson()).id, + }, + }) + + for (let i = 0; i < 29; i++) { + authenticatedUser = await jennyRostock.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: room?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + authenticatedUser = await user.toJson() + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: room?.CreateRoom.id, + content: faker.lorem.sentence(), + }, + }) + } + } + /* eslint-disable-next-line no-console */ console.log('Seeded Data...') await driver.close() diff --git a/backend/src/graphql/rooms.ts b/backend/src/graphql/rooms.ts index cb511c4eb..c9d3f54f2 100644 --- a/backend/src/graphql/rooms.ts +++ b/backend/src/graphql/rooms.ts @@ -13,8 +13,8 @@ export const createRoomMutation = () => { export const roomQuery = () => { return gql` - query { - Room { + query Room($first: Int, $offset: Int, $id: ID) { + Room(first: $first, offset: $offset, id: $id, orderBy: createdAt_desc) { id roomId roomName diff --git a/backend/src/schema/resolvers/rooms.spec.ts b/backend/src/schema/resolvers/rooms.spec.ts index 8c46794c7..17277ab13 100644 --- a/backend/src/schema/resolvers/rooms.spec.ts +++ b/backend/src/schema/resolvers/rooms.spec.ts @@ -51,6 +51,14 @@ describe('Room', () => { id: 'not-chatting-user', name: 'Not Chatting User', }), + Factory.build('user', { + id: 'second-chatting-user', + name: 'Second Chatting User', + }), + Factory.build('user', { + id: 'third-chatting-user', + name: 'Third Chatting User', + }), ]) }) @@ -366,6 +374,180 @@ describe('Room', () => { ).resolves.toMatchObject({ data: { UnreadRooms: 2, + } + }) + }) + }) + }) + }) + + describe('query several rooms', () => { + beforeAll(async () => { + authenticatedUser = await chattingUser.toJson() + await mutate({ + mutation: createRoomMutation(), + variables: { + userId: 'second-chatting-user', + }, + }) + await mutate({ + mutation: createRoomMutation(), + variables: { + userId: 'third-chatting-user', + }, + }) + }) + + it('returns the rooms paginated', async () => { + expect(await query({ query: roomQuery(), variables: { first: 2, offset: 0 } })).toMatchObject( + { + errors: undefined, + data: { + Room: [ + { + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Third Chatting User', + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'third-chatting-user', + id: 'third-chatting-user', + name: 'Third Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), + }, + { + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Second Chatting User', + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'second-chatting-user', + id: 'second-chatting-user', + name: 'Second Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), + }, + ], + }, + }, + ) + expect(await query({ query: roomQuery(), variables: { first: 2, offset: 2 } })).toMatchObject( + { + errors: undefined, + data: { + Room: [ + { + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Other Chatting User', + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'other-chatting-user', + id: 'other-chatting-user', + name: 'Other Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), + }, + ], + }, + }, + ) + }) + }) + + describe('query single room', () => { + let result: any = null + beforeAll(async () => { + authenticatedUser = await chattingUser.toJson() + result = await query({ query: roomQuery() }) + }) + describe('as chatter of room', () => { + it('returns the room', async () => { + expect( + await query({ + query: roomQuery(), + variables: { first: 2, offset: 0, id: result.data.Room[0].id }, + }), + ).toMatchObject({ + errors: undefined, + data: { + Room: [ + { + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Third Chatting User', + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'third-chatting-user', + id: 'third-chatting-user', + name: 'Third Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), + }, + ], + }, + }) + }) + describe('as not chatter of room', () => { + beforeAll(async () => { + authenticatedUser = await notChattingUser.toJson() + }) + it('returns no room', async () => { + authenticatedUser = await notChattingUser.toJson() + expect( + await query({ + query: roomQuery(), + variables: { first: 2, offset: 0, id: result.data.Room[0].id }, + }), + ).toMatchObject({ + errors: undefined, + data: { + Room: [], }, }) }) diff --git a/backend/src/schema/types/type/Room.gql b/backend/src/schema/types/type/Room.gql index 55984fb5f..80f61c83a 100644 --- a/backend/src/schema/types/type/Room.gql +++ b/backend/src/schema/types/type/Room.gql @@ -5,6 +5,11 @@ # users_some: _UserFilter # } +# TODO change this to last message date +enum _RoomOrdering { + createdAt_desc +} + type Room { id: ID! createdAt: String @@ -24,6 +29,9 @@ type Mutation { } type Query { - Room: [Room] + Room( + id: ID + orderBy: [_RoomOrdering] + ): [Room] UnreadRooms: Int } diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue index 95bf5da95..1eae487f5 100644 --- a/webapp/components/Chat/Chat.vue +++ b/webapp/components/Chat/Chat.vue @@ -13,13 +13,15 @@ :messages-loaded="messagesLoaded" :rooms="JSON.stringify(rooms)" :room-actions="JSON.stringify(roomActions)" - :rooms-loaded="true" + :rooms-loaded="roomsLoaded" + :loading-rooms="loadingRooms" show-files="false" show-audio="false" :styles="JSON.stringify(computedChatStyle)" :show-footer="true" @send-message="sendMessage($event.detail[0])" @fetch-messages="fetchMessages($event.detail[0])" + @fetch-more-rooms="fetchRooms" :responsive-breakpoint="responsiveBreakpoint" :single-room="singleRoom" show-reaction-emojis="false" @@ -143,17 +145,20 @@ export default { { name: 'deleteRoom', title: 'Delete Room' }, */ ], - rooms: [], - messages: [], - messagesLoaded: true, + showDemoOptions: true, responsiveBreakpoint: 600, + rooms: [], + roomsLoaded: false, + roomPage: 0, + roomPageSize: 10, // TODO pagination is a problem with single rooms - cant use singleRoom: !!this.singleRoomId || false, + selectedRoom: null, + loadingRooms: true, + messagesLoaded: false, messagePage: 0, messagePageSize: 20, - roomPage: 0, - roomPageSize: 999, // TODO pagination is a problem with single rooms - cant use - selectedRoom: null, + messages: [], } }, mounted() { @@ -165,8 +170,8 @@ export default { userId: this.singleRoomId, }, }) - .then(() => { - this.$apollo.queries.Rooms.refetch() + .then(({ data: { CreateRoom } }) => { + this.fetchRooms({ room: CreateRoom }) }) .catch((error) => { this.$toast.error(error) @@ -174,6 +179,8 @@ export default { .finally(() => { // this.loading = false }) + } else { + this.fetchRooms() } }, computed: { @@ -181,17 +188,54 @@ export default { currentUser: 'auth/user', }), computedChatStyle() { - // TODO light/dark theme still needed? - // return this.theme === 'light' ? chatStyle.STYLE.light : chatStyle.STYLE.dark return chatStyle.STYLE.light }, }, methods: { + async fetchRooms({ room } = {}) { + this.roomsLoaded = false + const offset = this.roomPage * this.roomPageSize + try { + const { + data: { Room }, + } = await this.$apollo.query({ + query: roomQuery(), + variables: { + id: room?.id, + first: this.roomPageSize, + offset, + }, + fetchPolicy: 'no-cache', + }) + + const newRooms = Room.map((r) => { + return { + ...r, + users: r.users.map((u) => { + return { ...u, username: u.name, avatar: u.avatar?.url } + }), + } + }) + + this.rooms = [...this.rooms, ...newRooms] + + if (Room.length < this.roomPageSize) { + this.roomsLoaded = true + } + this.roomPage += 1 + } catch (error) { + this.rooms = [] + this.$toast.error(error.message) + } + // must be set false after initial rooms are loaded and never changed again + this.loadingRooms = false + }, + async fetchMessages({ room, options = {} }) { - if (this.selectedRoom !== room.id) { + if (this.selectedRoom?.id !== room.id) { this.messages = [] this.messagePage = 0 - this.selectedRoom = room.id + this.selectedRoom = room } this.messagesLoaded = options.refetch ? this.messagesLoaded : false const offset = (options.refetch ? 0 : this.messagePage) * this.messagePageSize @@ -224,13 +268,6 @@ export default { } }, - refetchMessage(roomId) { - this.fetchMessages({ - room: this.rooms.find((r) => r.roomId === roomId), - options: { refetch: true }, - }) - }, - async sendMessage(message) { try { await this.$apollo.mutate({ @@ -243,7 +280,10 @@ export default { } catch (error) { this.$toast.error(error.message) } - this.refetchMessage(message.roomId) + this.fetchMessages({ + room: this.rooms.find((r) => r.roomId === message.roomId), + options: { refetch: true }, + }) }, getInitialsName(fullname) { @@ -251,45 +291,6 @@ export default { return fullname.match(/\b\w/g).join('').substring(0, 3).toUpperCase() }, }, - apollo: { - Rooms: { - query() { - return roomQuery() - }, - variables() { - return { - first: this.roomPageSize, - offset: this.roomPage * this.roomPageSize, - } - }, - update({ Room }) { - if (!Room) { - this.rooms = [] - return - } - - // Backend result needs mapping of the following values - // room[i].users[j].name -> room[i].users[j].username - // room[i].users[j].avatar.url -> room[i].users[j].avatar - // also filter rooms for the single room - this.rooms = Room.map((r) => { - return { - ...r, - users: r.users.map((u) => { - return { ...u, username: u.name, avatar: u.avatar?.url } - }), - } - }).filter((r) => - this.singleRoom ? r.users.filter((u) => u.id === this.singleRoomId).length > 0 : true, - ) - }, - error(error) { - this.rooms = [] - this.$toast.error(error.message) - }, - fetchPolicy: 'no-cache', - }, - }, }