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/graphql/rooms.ts b/backend/src/graphql/rooms.ts index 109bf1d55..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 @@ -30,3 +30,11 @@ export const roomQuery = () => { } ` } + +export const unreadRoomsQuery = () => { + return gql` + query { + UnreadRooms + } + ` +} diff --git a/backend/src/middleware/permissionsMiddleware.ts b/backend/src/middleware/permissionsMiddleware.ts index c07098a3c..f87f4b079 100644 --- a/backend/src/middleware/permissionsMiddleware.ts +++ b/backend/src/middleware/permissionsMiddleware.ts @@ -408,6 +408,7 @@ export default shield( getInviteCode: isAuthenticated, // and inviteRegistration Room: isAuthenticated, Message: isAuthenticated, + UnreadRooms: isAuthenticated, }, Mutation: { '*': deny, diff --git a/backend/src/schema/resolvers/rooms.spec.ts b/backend/src/schema/resolvers/rooms.spec.ts index 03c3d4456..690572e43 100644 --- a/backend/src/schema/resolvers/rooms.spec.ts +++ b/backend/src/schema/resolvers/rooms.spec.ts @@ -1,7 +1,8 @@ import { createTestClient } from 'apollo-server-testing' import Factory, { cleanDatabase } from '../../db/factories' import { getNeode, getDriver } from '../../db/neo4j' -import { createRoomMutation, roomQuery } from '../../graphql/rooms' +import { createRoomMutation, roomQuery, unreadRoomsQuery } from '../../graphql/rooms' +import { createMessageMutation } from '../../graphql/messages' import createServer from '../../server' const driver = getDriver() @@ -34,6 +35,8 @@ afterAll(async () => { }) describe('Room', () => { + let roomId: string + beforeAll(async () => { ;[chattingUser, otherChattingUser, notChattingUser] = await Promise.all([ Factory.build('user', { @@ -48,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', + }), ]) }) @@ -68,8 +79,6 @@ describe('Room', () => { }) describe('authenticated', () => { - let roomId: string - beforeAll(async () => { authenticatedUser = await chattingUser.toJson() }) @@ -260,4 +269,312 @@ describe('Room', () => { }) }) }) + + describe('unread rooms query', () => { + describe('unauthenticated', () => { + it('throws authorization error', async () => { + authenticatedUser = null + await expect( + query({ + query: unreadRoomsQuery(), + }), + ).resolves.toMatchObject({ + errors: [{ message: 'Not Authorized!' }], + }) + }) + }) + + describe('authenticated', () => { + let otherRoomId: string + + beforeAll(async () => { + authenticatedUser = await chattingUser.toJson() + const result = await mutate({ + mutation: createRoomMutation(), + variables: { + userId: 'not-chatting-user', + }, + }) + otherRoomId = result.data.CreateRoom.roomId + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: otherRoomId, + content: 'Message to not chatting user', + }, + }) + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId, + content: '1st message to other chatting user', + }, + }) + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId, + content: '2nd message to other chatting user', + }, + }) + authenticatedUser = await otherChattingUser.toJson() + const result2 = await mutate({ + mutation: createRoomMutation(), + variables: { + userId: 'not-chatting-user', + }, + }) + otherRoomId = result2.data.CreateRoom.roomId + await mutate({ + mutation: createMessageMutation(), + variables: { + roomId: otherRoomId, + content: 'Other message to not chatting user', + }, + }) + }) + + describe('as chatting user', () => { + it('has 0 unread rooms', async () => { + authenticatedUser = await chattingUser.toJson() + await expect( + query({ + query: unreadRoomsQuery(), + }), + ).resolves.toMatchObject({ + data: { + UnreadRooms: 0, + }, + }) + }) + }) + + describe('as other chatting user', () => { + it('has 1 unread rooms', async () => { + authenticatedUser = await otherChattingUser.toJson() + await expect( + query({ + query: unreadRoomsQuery(), + }), + ).resolves.toMatchObject({ + data: { + UnreadRooms: 1, + }, + }) + }) + }) + + describe('as not chatting user', () => { + it('has 2 unread rooms', async () => { + authenticatedUser = await notChattingUser.toJson() + await expect( + query({ + query: unreadRoomsQuery(), + }), + ).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: 3, 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), + }, + }, + ]), + }, + { + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Not Chatting User', + users: expect.arrayContaining([ + { + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }, + { + _id: 'not-chatting-user', + id: 'not-chatting-user', + name: 'Not Chatting User', + avatar: { + url: expect.any(String), + }, + }, + ]), + }, + ], + }, + }, + ) + expect(await query({ query: roomQuery(), variables: { first: 3, offset: 3 } })).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/resolvers/rooms.ts b/backend/src/schema/resolvers/rooms.ts index d5015a03b..cbf5bcd63 100644 --- a/backend/src/schema/resolvers/rooms.ts +++ b/backend/src/schema/resolvers/rooms.ts @@ -25,6 +25,27 @@ export default { } return resolved }, + UnreadRooms: async (object, params, context, resolveInfo) => { + const { + user: { id: currentUserId }, + } = context + const session = context.driver.session() + const readTxResultPromise = session.readTransaction(async (transaction) => { + const unreadRoomsCypher = ` + MATCH (:User { id: $currentUserId })-[:CHATS_IN]->(room:Room)<-[:INSIDE]-(message:Message)<-[:CREATED]-(sender:User) + WHERE NOT sender.id = $currentUserId AND NOT message.seen + RETURN toString(COUNT(DISTINCT room)) AS count + ` + const unreadRoomsTxResponse = await transaction.run(unreadRoomsCypher, { currentUserId }) + return unreadRoomsTxResponse.records.map((record) => record.get('count'))[0] + }) + try { + const count = await readTxResultPromise + return count + } finally { + session.close() + } + }, }, Mutation: { CreateRoom: async (_parent, params, context, _resolveInfo) => { diff --git a/backend/src/schema/types/type/Room.gql b/backend/src/schema/types/type/Room.gql index 2ce6556f6..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,5 +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 3e476034d..88d0b811c 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" @@ -129,17 +131,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() { @@ -151,8 +156,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) @@ -160,6 +165,8 @@ export default { .finally(() => { // this.loading = false }) + } else { + this.fetchRooms() } }, computed: { @@ -167,8 +174,6 @@ 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 }, textMessages() { @@ -189,6 +194,45 @@ export default { }, }, 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?.id !== room.id) { this.messages = [] @@ -212,6 +256,7 @@ export default { const msgs = [] ;[...this.messages, ...Message].forEach((m) => { + m.date = new Date(m.date).toDateString() msgs[m.indexId] = m }) this.messages = msgs.filter(Boolean) @@ -226,13 +271,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({ @@ -245,7 +283,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) { @@ -253,45 +294,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', - }, - }, }