diff --git a/.github/file-filters.yml b/.github/file-filters.yml index d7f9cb6c0..8d2d93fac 100644 --- a/.github/file-filters.yml +++ b/.github/file-filters.yml @@ -1,4 +1,5 @@ backend: &backend + - '.github/workflows/test-backend.yml' - 'backend/**/*' - 'neo4j/**/*' @@ -6,4 +7,5 @@ docker: &docker - 'docker-compose.*' webapp: &webapp + - '.github/workflows/test-webapp.yml' - 'webapp/**/*' diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 84d87c770..af53e1fbc 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -10,10 +10,11 @@ jobs: outputs: backend: ${{ steps.changes.outputs.backend }} docker: ${{ steps.changes.outputs.docker }} + pr-number: ${{ steps.pr.outputs.number }} steps: - uses: actions/checkout@v3.3.0 - - name: Check for frontend file changes + - name: Check for backend file changes uses: dorny/paths-filter@v2.11.1 id: changes with: @@ -21,6 +22,10 @@ jobs: filters: .github/file-filters.yml list-files: shell + - name: Get pr number + id: pr + uses: 8BitJonny/gh-get-current-pr@2.2.0 + build_test_neo4j: name: Docker Build Test - Neo4J if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.docker == 'true' @@ -34,12 +39,13 @@ jobs: run: | docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/ docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/neo4j.tar - - - name: Upload Artifact - uses: actions/upload-artifact@v3 + + - name: Cache docker images + id: cache-neo4j + uses: actions/cache/save@v3.3.1 with: - name: docker-neo4j-image path: /tmp/neo4j.tar + key: backend-neo4j-cache-pr${{ needs.files-changed.outputs.pr-number }} build_test_backend: name: Docker Build Test - Backend @@ -54,12 +60,13 @@ jobs: run: | docker build --target test -t "ocelotsocialnetwork/backend:test" backend/ docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar - - - name: Upload Artifact - uses: actions/upload-artifact@v3 + + - name: Cache docker images + id: cache-backend + uses: actions/cache/save@v3.3.1 with: - name: docker-backend-test path: /tmp/backend.tar + key: backend-cache-pr${{ needs.files-changed.outputs.pr-number }} lint_backend: name: Lint Backend @@ -84,28 +91,29 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - - name: Download Docker Image (Neo4J) - uses: actions/download-artifact@v3 + - name: Restore Neo4J cache + uses: actions/cache/restore@v3.3.1 with: - name: docker-neo4j-image - path: /tmp + path: /tmp/neo4j.tar + key: backend-neo4j-cache-pr${{ needs.files-changed.outputs.pr-number }} + fail-on-cache-miss: true - - name: Load Docker Image - run: docker load < /tmp/neo4j.tar - - - name: Download Docker Image (Backend) - uses: actions/download-artifact@v3 + - name: Restore Backend cache + uses: actions/cache/restore@v3.3.1 with: - name: docker-backend-test - path: /tmp + path: /tmp/backend.tar + key: backend-cache-pr${{ needs.files-changed.outputs.pr-number }} + fail-on-cache-miss: true - - name: Load Docker Image - run: docker load < /tmp/backend.tar + - name: Load Docker Images + run: | + docker load < /tmp/neo4j.tar + docker load < /tmp/backend.tar - - name: backend | copy env files webapp - run: cp webapp/.env.template webapp/.env - - name: backend | copy env files backend - run: cp backend/.env.template backend/.env + - name: backend | copy env files + run: | + cp webapp/.env.template webapp/.env + cp backend/.env.template backend/.env - name: backend | docker-compose run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps neo4j backend @@ -118,3 +126,20 @@ jobs: - name: backend | Unit test incl. coverage check run: docker-compose exec -T backend yarn test + + cleanup: + name: Cleanup + if: always() + needs: [files-changed, unit_test_backend] + runs-on: ubuntu-latest + steps: + - name: Delete cache + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh extension install actions/gh-actions-cache + set +e + KEY="backend-neo4j-cache-pr${{ needs.files-changed.outputs.pr-number }}" + gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm + KEY="backend-cache-pr${{ needs.files-changed.outputs.pr-number }}" + gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm diff --git a/.github/workflows/test-webapp.yml b/.github/workflows/test-webapp.yml index c1aee47cf..421ce5187 100644 --- a/.github/workflows/test-webapp.yml +++ b/.github/workflows/test-webapp.yml @@ -9,6 +9,7 @@ jobs: runs-on: ubuntu-latest outputs: docker: ${{ steps.changes.outputs.docker }} + pr-number: ${{ steps.pr.outputs.number }} webapp: ${{ steps.changes.outputs.webapp }} steps: - uses: actions/checkout@v3.3.0 @@ -21,6 +22,10 @@ jobs: filters: .github/file-filters.yml list-files: shell + - name: Get pr number + id: pr + uses: 8BitJonny/gh-get-current-pr@2.2.0 + prepare: name: Prepare if: needs.files-changed.outputs.webapp == 'true' @@ -34,7 +39,7 @@ jobs: run: | scripts/translations/sort.sh scripts/translations/missing-keys.sh - + build_test_webapp: name: Docker Build Test - Webapp if: needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true' @@ -44,16 +49,16 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - - name: webapp | Build 'test' image + - name: Webapp | Build 'test' image run: | docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/ docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar - - name: Upload Artifact - uses: actions/upload-artifact@v3 + - name: Cache docker image + uses: actions/cache/save@v3.3.1 with: - name: docker-webapp-test path: /tmp/webapp.tar + key: webapp-cache-pr${{ needs.files-changed.outputs.pr-number }} lint_webapp: name: Lint Webapp @@ -78,20 +83,19 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - - name: Download Docker Image (Webapp) - uses: actions/download-artifact@v3 + - name: Restore webapp cache + uses: actions/cache/restore@v3.3.1 with: - name: docker-webapp-test - path: /tmp + path: /tmp/webapp.tar + key: webapp-cache-pr${{ needs.files-changed.outputs.pr-number }} - name: Load Docker Image run: docker load < /tmp/webapp.tar - - name: backend | copy env files webapp - run: cp webapp/.env.template webapp/.env - - - name: backend | copy env files backend - run: cp backend/.env.template backend/.env + - name: Copy env files + run: | + cp webapp/.env.template webapp/.env + cp backend/.env.template backend/.env - name: backend | docker-compose run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp @@ -99,3 +103,18 @@ jobs: - name: webapp | Unit tests incl. coverage check run: docker-compose exec -T webapp yarn test + cleanup: + name: Cleanup + if: always() + needs: [files-changed, unit_test_webapp] + runs-on: ubuntu-latest + steps: + - name: Delete cache + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh extension install actions/gh-actions-cache + set +e + KEY="webapp-cache-pr${{ needs.files-changed.outputs.pr-number }}" + gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm + diff --git a/backend/src/graphql/messages.ts b/backend/src/graphql/messages.ts index fde45083b..2842c7230 100644 --- a/backend/src/graphql/messages.ts +++ b/backend/src/graphql/messages.ts @@ -27,6 +27,9 @@ export const messageQuery = () => { indexId content senderId + author { + id + } username avatar date diff --git a/backend/src/graphql/rooms.ts b/backend/src/graphql/rooms.ts index 294b50641..7612641f3 100644 --- a/backend/src/graphql/rooms.ts +++ b/backend/src/graphql/rooms.ts @@ -9,6 +9,7 @@ export const createRoomMutation = () => { roomName lastMessageAt unreadCount + #avatar users { _id id @@ -25,10 +26,11 @@ export const createRoomMutation = () => { export const roomQuery = () => { return gql` query Room($first: Int, $offset: Int, $id: ID) { - Room(first: $first, offset: $offset, id: $id, orderBy: createdAt_desc) { + Room(first: $first, offset: $offset, id: $id, orderBy: lastMessageAt_desc) { id roomId roomName + avatar lastMessageAt unreadCount lastMessage { diff --git a/backend/src/middleware/chatMiddleware.ts b/backend/src/middleware/chatMiddleware.ts index c28d6a70d..8ae252e13 100644 --- a/backend/src/middleware/chatMiddleware.ts +++ b/backend/src/middleware/chatMiddleware.ts @@ -54,4 +54,7 @@ export default { Mutation: { CreateRoom: roomProperties, }, + Subscription: { + chatMessageAdded: messageProperties, + }, } diff --git a/backend/src/schema/resolvers/messages.spec.ts b/backend/src/schema/resolvers/messages.spec.ts index 1679b0c34..83d9fdc6b 100644 --- a/backend/src/schema/resolvers/messages.spec.ts +++ b/backend/src/schema/resolvers/messages.spec.ts @@ -117,7 +117,7 @@ describe('Message', () => { }) describe('user chats in room', () => { - it('returns the message and publishes subscription', async () => { + it('returns the message and publishes subscriptions', async () => { await expect( mutate({ mutation: createMessageMutation(), @@ -146,6 +146,20 @@ describe('Message', () => { roomCountUpdated: '1', userId: 'other-chatting-user', }) + expect(pubsubSpy).toBeCalledWith('CHAT_MESSAGE_ADDED', { + chatMessageAdded: expect.objectContaining({ + id: expect.any(String), + content: 'Some nice message to other chatting user', + senderId: 'chatting-user', + username: 'Chatting User', + avatar: expect.any(String), + date: expect.any(String), + saved: true, + distributed: false, + seen: false, + }), + userId: 'other-chatting-user', + }) }) describe('room is updated as well', () => { diff --git a/backend/src/schema/resolvers/messages.ts b/backend/src/schema/resolvers/messages.ts index a908f3fd8..e473c2bb5 100644 --- a/backend/src/schema/resolvers/messages.ts +++ b/backend/src/schema/resolvers/messages.ts @@ -1,7 +1,9 @@ import { neo4jgraphql } from 'neo4j-graphql-js' import Resolver from './helpers/Resolver' + import { getUnreadRoomsCount } from './rooms' -import { pubsub, ROOM_COUNT_UPDATED } from '../../server' +import { pubsub, ROOM_COUNT_UPDATED, CHAT_MESSAGE_ADDED } from '../../server' +import { withFilter } from 'graphql-subscriptions' const setMessagesAsDistributed = async (undistributedMessagesIds, session) => { return session.writeTransaction(async (transaction) => { @@ -19,6 +21,16 @@ const setMessagesAsDistributed = async (undistributedMessagesIds, session) => { } export default { + Subscription: { + chatMessageAdded: { + subscribe: withFilter( + () => pubsub.asyncIterator(CHAT_MESSAGE_ADDED), + (payload, variables) => { + return payload.userId === variables.userId + }, + ), + }, + }, Query: { Message: async (object, params, context, resolveInfo) => { const { roomId } = params @@ -102,10 +114,14 @@ export default { const roomCountUpdated = await getUnreadRoomsCount(message.recipientId, session) // send subscriptions - await pubsub.publish(ROOM_COUNT_UPDATED, { + void pubsub.publish(ROOM_COUNT_UPDATED, { roomCountUpdated, userId: message.recipientId, }) + void pubsub.publish(CHAT_MESSAGE_ADDED, { + chatMessageAdded: message, + userId: message.recipientId, + }) } return message diff --git a/backend/src/schema/resolvers/rooms.spec.ts b/backend/src/schema/resolvers/rooms.spec.ts index ee291a6c9..2e26dc1e3 100644 --- a/backend/src/schema/resolvers/rooms.spec.ts +++ b/backend/src/schema/resolvers/rooms.spec.ts @@ -423,125 +423,147 @@ describe('Room', () => { }) it('returns the rooms paginated', async () => { - expect(await query({ query: roomQuery(), variables: { first: 3, offset: 0 } })).toMatchObject( - { - errors: undefined, - data: { - Room: [ - { + await expect( + query({ query: roomQuery(), variables: { first: 3, offset: 0 } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + Room: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Third Chatting User', + lastMessageAt: null, + unreadCount: 0, + lastMessage: null, + users: expect.arrayContaining([ + expect.objectContaining({ + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }), + expect.objectContaining({ + _id: 'third-chatting-user', + id: 'third-chatting-user', + name: 'Third Chatting User', + avatar: { + url: expect.any(String), + }, + }), + ]), + }), + expect.objectContaining({ + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Second Chatting User', + lastMessageAt: null, + unreadCount: 0, + lastMessage: null, + users: expect.arrayContaining([ + expect.objectContaining({ + _id: 'chatting-user', + id: 'chatting-user', + name: 'Chatting User', + avatar: { + url: expect.any(String), + }, + }), + expect.objectContaining({ + _id: 'second-chatting-user', + id: 'second-chatting-user', + name: 'Second Chatting User', + avatar: { + url: expect.any(String), + }, + }), + ]), + }), + expect.objectContaining({ + id: expect.any(String), + roomId: expect.any(String), + roomName: 'Other Chatting User', + lastMessageAt: expect.any(String), + unreadCount: 0, + lastMessage: { + _id: expect.any(String), 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), - }, - }, - ]), + content: '2nd message to other chatting user', + senderId: 'chatting-user', + username: 'Chatting User', + avatar: expect.any(String), + date: expect.any(String), + saved: true, + distributed: false, + seen: false, }, - { - 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), - }, + users: expect.arrayContaining([ + expect.objectContaining({ + _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.objectContaining({ + _id: 'other-chatting-user', + id: 'other-chatting-user', + name: 'Other 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), - }, + }) + await expect( + query({ query: roomQuery(), variables: { first: 3, offset: 3 } }), + ).resolves.toMatchObject({ + errors: undefined, + data: { + Room: [ + expect.objectContaining({ + 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: 'other-chatting-user', - id: 'other-chatting-user', - name: 'Other Chatting User', - avatar: { - url: expect.any(String), - }, + }, + { + _id: 'not-chatting-user', + id: 'not-chatting-user', + name: 'Not 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( @@ -556,34 +578,19 @@ describe('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), - }, - }, - ]), + roomName: result.data.Room[0].roomName, + users: expect.any(Array), }, ], }, }) }) + describe('as not chatter of room', () => { beforeAll(async () => { authenticatedUser = await notChattingUser.toJson() }) + it('returns no room', async () => { authenticatedUser = await notChattingUser.toJson() expect( diff --git a/backend/src/schema/types/type/Message.gql b/backend/src/schema/types/type/Message.gql index 764181dd9..71d175e1c 100644 --- a/backend/src/schema/types/type/Message.gql +++ b/backend/src/schema/types/type/Message.gql @@ -44,3 +44,7 @@ type Query { orderBy: [_MessageOrdering] ): [Message] } + +type Subscription { + chatMessageAdded(userId: ID!): Message +} diff --git a/backend/src/schema/types/type/Room.gql b/backend/src/schema/types/type/Room.gql index fdce6865b..0cf5b22c8 100644 --- a/backend/src/schema/types/type/Room.gql +++ b/backend/src/schema/types/type/Room.gql @@ -7,7 +7,7 @@ # TODO change this to last message date enum _RoomOrdering { - createdAt_desc + lastMessageAt_desc } type Room { diff --git a/backend/src/server.ts b/backend/src/server.ts index feceeb9eb..0522f5fc8 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,7 +14,7 @@ import bodyParser from 'body-parser' import { graphqlUploadExpress } from 'graphql-upload' export const NOTIFICATION_ADDED = 'NOTIFICATION_ADDED' -// export const CHAT_MESSAGE_ADDED = 'CHAT_MESSAGE_ADDED' +export const CHAT_MESSAGE_ADDED = 'CHAT_MESSAGE_ADDED' export const ROOM_COUNT_UPDATED = 'ROOM_COUNT_UPDATED' const { REDIS_DOMAIN, REDIS_PORT, REDIS_PASSWORD } = CONFIG let prodPubsub, devPubsub diff --git a/webapp/components/Chat/Chat.vue b/webapp/components/Chat/Chat.vue index 43994ef5d..3a059f64e 100644 --- a/webapp/components/Chat/Chat.vue +++ b/webapp/components/Chat/Chat.vue @@ -61,7 +61,12 @@