Compare commits

..

No commits in common. "master" and "3.12.2" have entirely different histories.

1573 changed files with 20686 additions and 124037 deletions

View File

@ -1,99 +0,0 @@
# CodeRabbit Configuration
# https://docs.coderabbit.ai/guides/configure-coderabbit
language: de
reviews:
# Automatisches Review für alle PRs
auto_review:
enabled: true
drafts: false
base_branches:
- master
- main
auto_approve:
enabled: true
# Review-Einstellungen
request_changes_workflow: true
high_level_summary: true
poem: false
review_status: true
collapse_walkthrough: false
changed_files_summary: true
# Pfad-Filter (Dateien die ignoriert werden)
path_filters:
- "!**/*.lock"
- "!**/package-lock.json"
- "!**/yarn.lock"
- "!**/*.snap"
- "!**/coverage/**"
- "!**/dist/**"
- "!**/node_modules/**"
- "!**/*.min.js"
- "!**/*.min.css"
- "!**/.git/**"
- "!**/storybook-static/**"
# Instruktionen für spezifische Pfade
path_instructions:
- path: "webapp/**/*.vue"
instructions: |
Prüfe Vue.js Best Practices:
- Composition API Verwendung
- Props Validierung
- Event-Handling
- Reaktivität
- path: "webapp/**/*.spec.js"
instructions: |
Prüfe Test-Qualität:
- Aussagekräftige Test-Namen
- Edge Cases abgedeckt
- Mocking korrekt verwendet
- path: "backend/**/*.js"
instructions: |
Prüfe Backend Best Practices:
- Error Handling
- Input Validierung
- SQL Injection Prevention
- Performance (N+1 Queries)
- path: "packages/ui/**/*.ts"
instructions: |
Prüfe UI Library Standards:
- TypeScript Typisierung
- Vue 2/3 Kompatibilität (vue-demi)
- Accessibility (WCAG 2.1)
- CVA Varianten-Pattern
- path: "packages/ui/**/*.vue"
instructions: |
Prüfe UI Komponenten:
- Render-Funktion Pattern für vue-demi
- Props mit korrekten Types
- Slots dokumentiert
- Keine Template-Syntax (nur h() für Vue 2/3 Kompatibilität)
# Chat-Befehle
chat:
auto_reply: true
# Ton und Stil
tone_instructions: |
Sei konstruktiv und freundlich.
Erkläre das "Warum" hinter Vorschlägen.
Priorisiere Sicherheit > Korrektheit > Performance > Lesbarkeit.
Schlage konkrete Code-Änderungen vor wenn möglich.
# Knowledge Base (Repository-spezifisches Wissen)
knowledge_base:
learnings:
scope: auto
issues:
scope: auto
pull_requests:
scope: auto

View File

@ -126,71 +126,3 @@ updates:
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
# ui library
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/packages/ui"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
groups:
vue:
applies-to: version-updates
patterns:
- "vue*"
- "@vue*"
vite:
applies-to: version-updates
patterns:
- "vite*"
- "@vitejs*"
vitest:
applies-to: version-updates
patterns:
- "vitest*"
- "@vitest*"
# ui examples
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/packages/ui/examples/vue3-tailwind"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/packages/ui/examples/vue3-css"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/packages/ui/examples/vue2-tailwind"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/packages/ui/examples/vue2-css"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"

View File

@ -1,9 +1,5 @@
# These file filter patterns are used by the action https://github.com/dorny/paths-filter
ui: &ui
- '.github/workflows/ui-*.yml'
- 'packages/ui/**/*'
backend: &backend
- '.github/workflows/test-backend.yml'
- 'backend/**/*'
@ -16,9 +12,7 @@ docker: &docker
webapp: &webapp
- '.github/workflows/test-webapp.yml'
- 'webapp/**/*'
- 'styleguide/**/*'
- 'package.json'
- *ui
docs-check: &docs-check
- '.github/workflows/check-documentation.yml'

View File

@ -11,7 +11,7 @@ jobs:
documentation: ${{ steps.changes.outputs.documentation }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Check for markdown file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@ -28,13 +28,13 @@ jobs:
if: needs.files-changed.outputs.markdown == 'true'
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Remove uncheckable documentation files
run: rm -rf ./CHANGELOG.md # workaround until https://github.com/gaurav-nelson/github-action-markdown-link-check/pull/183 has been done
- name: Check Markdown Links
uses: gaurav-nelson/github-action-markdown-link-check@3c3b66f1f7d0900e37b71eca45b63ea9eedfce31 # 1.0.15
uses: gaurav-nelson/github-action-markdown-link-check@1b916f2cf6c36510a6059943104e3c42ce6c16bc # 1.0.15
with:
use-quiet-mode: 'yes'
use-verbose-mode: 'no'
@ -51,10 +51,10 @@ jobs:
if: needs.files-changed.outputs.documentation == 'true'
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Setup Node 20
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v4.0.3
with:
node-version: '20'

View File

@ -0,0 +1,42 @@
###############################################################################
# A Github repo has max 10 GB of cache.
# https://github.blog/changelog/2021-11-23-github-actions-cache-size-is-now-increased-to-10gb-per-repository/
#
# To avoid "cache thrashing" by their cache eviction policy it is recommended
# to apply a cache cleanup workflow at PR closing to dele cache leftovers of
# the current branch:
# https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
###############################################################################
name: ocelot.social cache cleanup on pr closing
on:
pull_request:
types:
- closed
jobs:
clean-branch-cache:
name: Cleanup branch cache
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
REPO=${{ github.repository }}
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -13,7 +13,7 @@ jobs:
documentation: ${{ steps.changes.outputs.documentation }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@ -27,10 +27,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Setup Node 20
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v4.0.3
with:
node-version: 20

View File

@ -24,27 +24,27 @@ jobs:
file: backend/Dockerfile
target: production
- name: webapp-base
context: .
context: webapp
file: webapp/Dockerfile
target: base
- name: webapp-build
context: .
context: webapp
file: webapp/Dockerfile
target: build
- name: webapp
context: .
context: webapp
file: webapp/Dockerfile
target: production
- name: maintenance-base
context: .
context: webapp
file: webapp/Dockerfile.maintenance
target: base
- name: maintenance-build
context: .
context: webapp
file: webapp/Dockerfile.maintenance
target: build
- name: maintenance
context: .
context: webapp
file: webapp/Dockerfile.maintenance
target: production
runs-on: ubuntu-latest
@ -59,16 +59,16 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Log in to the Container registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |

View File

@ -14,13 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
with:
fetch-depth: 0 # Fetch full History for changelog
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
with:
node-version-file: '.nvmrc'
- name: Setup env
run: |
echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV
@ -58,13 +54,9 @@ jobs:
needs: [github_tag]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
with:
fetch-depth: 0 # Fetch full History for changelog
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
with:
node-version-file: '.nvmrc'
- name: Setup env
run: |
echo "VERSION=$(node -p -e "require('./package.json').version")" >> $GITHUB_ENV
@ -72,7 +64,7 @@ jobs:
echo "BUILD_COMMIT=${GITHUB_SHA}" >> $GITHUB_ENV
- run: echo "BUILD_VERSION=${VERSION}-${GITHUB_RUN_NUMBER}" >> $GITHUB_ENV
#- name: Repository Dispatch
# uses: peter-evans/repository-dispatch@f49a8ac5751834a0666df77deb0289abbe2b3a78 # v3.0.0
# uses: peter-evans/repository-dispatch@7279ea08e172078316f128ed1118df40d2904f0f # v3.0.0
# with:
# token: ${{ github.token }}
# event-type: trigger-ocelot-build-success
@ -80,7 +72,7 @@ jobs:
# client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "VERSION": "${VERSION}", "BUILD_DATE": "${BUILD_DATE}", "BUILD_COMMIT": "${BUILD_COMMIT}", "BUILD_VERSION": "${BUILD_VERSION}"}'
- name: Repository Dispatch stage.ocelot.social
uses: peter-evans/repository-dispatch@f49a8ac5751834a0666df77deb0289abbe2b3a78 # v3.0.0
uses: peter-evans/repository-dispatch@7279ea08e172078316f128ed1118df40d2904f0f # v3.0.0
with:
token: ${{ secrets.OCELOT_PUBLISH_EVENT_PAT }} # this token is required to access the other repository
event-type: trigger-ocelot-build-success
@ -88,7 +80,7 @@ jobs:
client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "GITHUB_RUN_NUMBER": "${{ env.GITHUB_RUN_NUMBER }}", "VERSION": "${VERSION}", "BUILD_DATE": "${BUILD_DATE}", "BUILD_COMMIT": "${BUILD_COMMIT}", "BUILD_VERSION": "${BUILD_VERSION}"}'
- name: Repository Dispatch stage.yunite.me
uses: peter-evans/repository-dispatch@f49a8ac5751834a0666df77deb0289abbe2b3a78 # v3.0.0
uses: peter-evans/repository-dispatch@7279ea08e172078316f128ed1118df40d2904f0f # v3.0.0
with:
token: ${{ secrets.OCELOT_PUBLISH_EVENT_PAT }} # this token is required to access the other repository
event-type: trigger-ocelot-build-success

View File

@ -11,7 +11,7 @@ jobs:
backend: ${{ steps.changes.outputs.backend }}
docker: ${{ steps.changes.outputs.docker }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Check for backend file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Neo4J | Build 'community' image
run: |
@ -37,7 +37,7 @@ jobs:
- name: Cache docker images
id: cache-neo4j
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: /tmp/neo4j.tar
key: ${{ github.run_id }}-backend-neo4j-cache
@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: backend | Build 'test' image
run: |
@ -58,7 +58,7 @@ jobs:
- name: Cache docker images
id: cache-backend
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: /tmp/backend.tar
key: ${{ github.run_id }}-backend-cache
@ -70,12 +70,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
with:
node-version-file: 'backend/.nvmrc'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: backend | Lint
run: cd backend && yarn && yarn run lint
@ -89,17 +84,17 @@ jobs:
checks: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Restore Neo4J cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: /tmp/neo4j.tar
key: ${{ github.run_id }}-backend-neo4j-cache
fail-on-cache-miss: true
- name: Restore Backend cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: /tmp/backend.tar
key: ${{ github.run_id }}-backend-cache
@ -127,3 +122,20 @@ jobs:
- name: backend | Unit test incl. coverage check
run: docker compose exec -T backend yarn test
cleanup:
name: Cleanup
if: ${{ needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.docker == 'true' }}
needs: [files-changed, unit_test_backend]
runs-on: ubuntu-latest
permissions: write-all
continue-on-error: true
steps:
- name: Delete cache
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh extension install actions/gh-actions-cache
KEY="${{ github.run_id }}-backend-neo4j-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm
KEY="${{ github.run_id }}-backend-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm

View File

@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2
- name: Copy backend env file
run: |
@ -31,7 +31,7 @@ jobs:
docker compose -f docker-compose.yml -f docker-compose.test.yml down
- name: Cache docker images
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: |
/tmp/backend.tar
@ -46,7 +46,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2
- name: Copy backend env file
run: |
@ -59,7 +59,7 @@ jobs:
docker save "ghcr.io/ocelot-social-community/ocelot-social/webapp:test" > /tmp/webapp.tar
- name: Cache docker image
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: /tmp/webapp.tar
key: ${{ github.run_id }}-e2e-webapp-cache
@ -68,16 +68,13 @@ jobs:
name: Fullstack | prepare cypress
runs-on: ubuntu-latest
steps:
- name: Delete huge unnecessary tools folder
run: rm -rf /opt/hostedtoolcache
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.4.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v4.4.0
with:
node-version-file: 'backend/.nvmrc'
node-version-file: 'backend/.tool-versions'
cache: 'yarn'
- name: Copy env files
@ -87,8 +84,7 @@ jobs:
- name: Install cypress requirements
run: |
sudo wget --no-verbose -O /opt/cucumber-json-formatter "https://github.com/cucumber/json-formatter/releases/download/v19.0.0/cucumber-json-formatter-linux-386"
sudo chmod +x /opt/cucumber-json-formatter
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
@ -97,7 +93,7 @@ jobs:
- name: Cache docker image
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: |
/opt/cucumber-json-formatter
@ -105,45 +101,29 @@ jobs:
/home/runner/work/Ocelot-Social/Ocelot-Social
key: ${{ github.run_id }}-e2e-cypress
list_features:
name: List Feature Files
runs-on: ubuntu-latest
outputs:
features: ${{ steps.list.outputs.features }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
- name: List feature files
id: list
run: |
FEATURES=$(find cypress/e2e/ -maxdepth 1 -name "*.feature" -printf '%f\n' | sort | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "features=$FEATURES" >> $GITHUB_OUTPUT
fullstack_tests:
name: E2E | ${{ matrix.feature }}
name: Fullstack | tests
if: success()
needs: [prepare_backend_environment, prepare_webapp_image, prepare_cypress, list_features]
needs: [prepare_backend_environment, prepare_webapp_image, prepare_cypress]
runs-on: ubuntu-latest
env:
jobs: 8
strategy:
fail-fast: false
matrix:
feature: ${{ fromJson(needs.list_features.outputs.features) }}
# run copies of the current job in parallel
job: [1, 2, 3, 4, 5, 6, 7, 8]
steps:
- name: Delete huge unnecessary tools folder
run: rm -rf /opt/hostedtoolcache
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.4.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v4.4.0
with:
node-version-file: 'backend/.nvmrc'
node-version-file: 'backend/.tool-versions'
cache: 'yarn'
- name: Restore cypress cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: |
/opt/cucumber-json-formatter
@ -153,7 +133,7 @@ jobs:
restore-keys: ${{ github.run_id }}-e2e-cypress
- name: Restore backend environment cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: |
/tmp/backend.tar
@ -164,7 +144,7 @@ jobs:
key: ${{ github.run_id }}-e2e-backend-environment-cache
- name: Restore webapp cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: /tmp/webapp.tar
key: ${{ github.run_id }}-e2e-webapp-cache
@ -184,7 +164,7 @@ jobs:
- name: Full stack tests | run tests
id: e2e-tests
run: yarn run cypress:run --spec "cypress/e2e/${{ matrix.feature }}"
run: yarn run cypress:run --spec $(cypress/parallel-features.sh ${{ matrix.job }} ${{ env.jobs }} )
- name: Full stack tests | if tests failed, compile html report
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
@ -195,21 +175,29 @@ jobs:
- name: Full stack tests | if tests failed, upload report
id: e2e-report
if: ${{ failure() && steps.e2e-tests.conclusion == 'failure' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: e2e-report-${{ matrix.feature }}
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
e2e_status:
name: E2E | Status
if: always()
needs: [fullstack_tests]
cleanup_cache:
name: Cleanup Cache
needs: fullstack_tests
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Check E2E results
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2
- name: Full stack tests | cleanup cache
run: |
if [ "${{ needs.fullstack_tests.result }}" != "success" ]; then
echo "E2E tests failed or were cancelled (result: ${{ needs.fullstack_tests.result }})"
exit 1
fi
cacheKeys=$(gh cache list --json key --jq '.[] | select(.key | startswith("${{ github.run_id }}-e2e-")) | .key')
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeys
do
gh cache delete "$cacheKey"
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -11,7 +11,7 @@ jobs:
docker: ${{ steps.changes.outputs.docker }}
webapp: ${{ steps.changes.outputs.webapp }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Check for frontend file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
@ -28,12 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
with:
node-version-file: 'webapp/.nvmrc'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Check translation files
run: |
@ -47,15 +42,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Webapp | Build 'test' image
run: |
docker build --target test -f webapp/Dockerfile -t "ghcr.io/ocelot-social-community/ocelot-social/webapp:test" .
docker save "ghcr.io/ocelot-social-community/ocelot-social/webapp:test" > /tmp/webapp.tar
docker build --target test -t "ocelotsocialnetwork/webapp:test" webapp/
docker save "ocelotsocialnetwork/webapp:test" > /tmp/webapp.tar
- name: Cache docker image
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: /tmp/webapp.tar
key: ${{ github.run_id }}-webapp-cache
@ -67,12 +62,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v4.0.3
with:
node-version-file: 'webapp/.nvmrc'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: webapp | Lint
run: cd webapp && yarn && yarn run lint
@ -86,10 +76,10 @@ jobs:
checks: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.1.7
- name: Restore webapp cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v4.0.2
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.0.2
with:
path: /tmp/webapp.tar
key: ${{ github.run_id }}-webapp-cache
@ -102,8 +92,26 @@ jobs:
cp webapp/.env.template webapp/.env
cp backend/.env.template backend/.env
- name: Start webapp container
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp
- name: backend | docker compose
# doesn't work without the --build flag - this either means we should not load the cached images or cache the correct image
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps webapp --build
- name: webapp | Unit tests incl. coverage check
run: docker compose exec -T webapp yarn test
cleanup:
name: Cleanup
if: ${{ needs.files-changed.outputs.docker == 'true' || needs.files-changed.outputs.webapp == 'true' }}
needs: [files-changed, unit_test_webapp]
runs-on: ubuntu-latest
permissions: write-all
continue-on-error: true
steps:
- name: Delete cache
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh extension install actions/gh-actions-cache
KEY="${{ github.run_id }}-webapp-cache"
gh actions-cache delete $KEY -R Ocelot-Social-Community/Ocelot-Social --confirm

View File

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.actor != 'dependabot[bot]' }}
steps:
- uses: amannn/action-semantic-pull-request@069817c298f23fab00a8f29a2e556a5eac0f6390 # v5.5.3
- uses: amannn/action-semantic-pull-request@e7d011b07ef37e089bea6539210f6a0d360d8af9 # v5.5.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@ -29,11 +29,9 @@ jobs:
# Configure which scopes are allowed (newline delimited).
scopes: |
backend
package/ui
webapp
maintenance
database
e2e
docu
docker
release

View File

@ -1,95 +0,0 @@
name: UI Build
on:
push:
branches: [master]
pull_request:
branches: [master]
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
build:
name: Build
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build library
run: npm run build
- name: Verify build output
run: |
echo "Checking build output..."
# Check that dist directory exists
if [ ! -d "dist" ]; then
echo "::error::dist directory not found"
exit 1
fi
# Check required files exist
FILES=(
"dist/index.mjs"
"dist/index.cjs"
"dist/index.d.ts"
"dist/index.d.cts"
"dist/tailwind.preset.mjs"
"dist/tailwind.preset.cjs"
"dist/tailwind.preset.d.ts"
"dist/tailwind.preset.d.cts"
"dist/style.css"
)
for file in "${FILES[@]}"; do
if [ ! -f "$file" ]; then
echo "::error::Missing required file: $file"
exit 1
fi
echo "✓ $file"
done
echo ""
echo "All build outputs verified!"
- name: Validate package
run: npm run validate
- name: Upload build artifacts
uses: actions/upload-artifact@v6
with:
name: dist
path: packages/ui/dist/
retention-days: 7

View File

@ -1,121 +0,0 @@
name: UI Compatibility
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
build-library:
name: Build Library
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/ui
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build library
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v6
with:
name: ui-dist
path: packages/ui/dist/
retention-days: 1
test-compatibility:
name: Test Compatibility
if: needs.files-changed.outputs.ui == 'true'
needs: [files-changed, build-library]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
example:
- vue3-tailwind
- vue3-css
- vue2-tailwind
- vue2-css
defaults:
run:
working-directory: packages/ui/examples/${{ matrix.example }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: ui-dist
path: packages/ui/dist/
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/examples/${{ matrix.example }}/package-lock.json
- name: Install dependencies
run: npm install
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Build example app
run: npm run build
compatibility-result:
name: Compatibility Result
if: always()
needs: [files-changed, test-compatibility]
runs-on: ubuntu-latest
steps:
- name: Skip if no UI changes
if: needs.files-changed.outputs.ui != 'true'
run: echo "No UI changes detected, skipping."
- name: Check matrix results
if: needs.files-changed.outputs.ui == 'true'
run: |
if [ "${{ needs.test-compatibility.result }}" != "success" ]; then
echo "Compatibility tests failed"
exit 1
fi

View File

@ -1,59 +0,0 @@
name: UI Docker
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
build:
name: Build Docker Image
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build development image
uses: docker/build-push-action@v6
with:
context: ./packages/ui
file: ./packages/ui/Dockerfile
target: development
push: false
tags: ocelot-social/ui:development
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build production image
uses: docker/build-push-action@v6
with:
context: ./packages/ui
file: ./packages/ui/Dockerfile
target: production
push: false
tags: ocelot-social/ui:latest
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@ -1,54 +0,0 @@
name: UI Lint
on:
push:
branches: [master]
pull_request:
branches: [master]
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
lint:
name: ESLint
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Run TypeScript type check
run: npm run typecheck

View File

@ -1,65 +0,0 @@
name: UI Release
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
- 'release-please-config.json'
- '.release-please-manifest.json'
permissions:
contents: write
pull-requests: write
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs['packages/ui--release_created'] }}
tag_name: ${{ steps.release.outputs['packages/ui--tag_name'] }}
version: ${{ steps.release.outputs['packages/ui--version'] }}
steps:
- name: Release Please
id: release
uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
publish:
name: Publish to npm
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/ui
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'packages/ui/.tool-versions'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Validate package
run: npm run validate
- name: Publish to npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -1,54 +0,0 @@
name: UI Size
on:
push:
branches: [master]
pull_request:
branches: [master]
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
size:
name: Bundle Size Check
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Check bundle size
run: npm run size

View File

@ -1,74 +0,0 @@
name: UI Storybook
on:
push:
branches: [master]
pull_request:
branches: [master]
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
build:
name: Build Storybook
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run storybook:build
- name: Verify build output
run: |
echo "Checking Storybook build output..."
if [ ! -d "storybook-static" ]; then
echo "::error::storybook-static directory not found"
exit 1
fi
if [ ! -f "storybook-static/index.html" ]; then
echo "::error::index.html not found in storybook-static"
exit 1
fi
echo "✓ Storybook build verified!"
- name: Upload Storybook artifacts
uses: actions/upload-artifact@v6
with:
name: storybook-static
path: packages/ui/storybook-static/
retention-days: 7

View File

@ -1,59 +0,0 @@
name: UI Test
on:
push:
branches: [master]
pull_request:
branches: [master]
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
test:
name: Unit Tests
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v6
if: always()
with:
name: coverage-report
path: packages/ui/coverage/
retention-days: 7

View File

@ -1,51 +0,0 @@
name: UI Verify
on:
push:
branches: [master]
pull_request:
branches: [master]
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
verify:
name: Completeness Check
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check component completeness
run: npm run verify

View File

@ -1,64 +0,0 @@
name: UI Visual
on:
push:
branches: [master]
pull_request:
branches: [master]
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
visual:
name: Visual Regression
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Run visual tests
run: npm run test:visual
- name: Upload test results
uses: actions/upload-artifact@v6
if: failure()
with:
name: visual-test-results
path: |
packages/ui/test-results/
packages/ui/playwright-report/
retention-days: 7

3
.gitmodules vendored
View File

@ -1,3 +1,6 @@
[submodule "styleguide"]
path = styleguide
url = https://github.com/Human-Connection/Nitro-Styleguide.git
[submodule "deployment/configurations/stage.ocelot.social"]
path = deployment/configurations/stage.ocelot.social
url = git@github.com:Ocelot-Social-Community/stage.ocelot.social.git

2
.nvmrc
View File

@ -1 +1 @@
v25.3.0
v24.2.0

View File

@ -1,3 +0,0 @@
{
"packages/ui": "0.0.1"
}

1
.tool-versions Normal file
View File

@ -0,0 +1 @@
nodejs 20.12.1

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,7 @@ SMTP_PASSWORD=
SMTP_SECURE="false" # true for 465, false for other ports
SMTP_DKIM_DOMAINNAME=
SMTP_DKIM_KEYSELECTOR=
SMTP_DKIM_PRIVATEKEY=
SMTP_DKIM_PRIVATKEY=
# E-Mail settings for our 'docker compose up mailserver'
# SMTP_HOST=localhost
# SMTP_PORT=1025
@ -48,4 +48,3 @@ IMAGOR_SECRET=mysecret
CATEGORIES_ACTIVE=false
MAX_PINNED_POSTS=1
MAX_GROUP_PINNED_POSTS=1

View File

@ -19,7 +19,7 @@ SMTP_PASSWORD=
SMTP_SECURE="false" # true for 465, false for other ports
SMTP_DKIM_DOMAINNAME=
SMTP_DKIM_KEYSELECTOR=
SMTP_DKIM_PRIVATEKEY=
SMTP_DKIM_PRIVATKEY=
JWT_SECRET="b/&&7b78BF&fv/Vd"
JWT_EXPIRES="2y"
@ -40,4 +40,3 @@ IMAGOR_SECRET=mysecret
CATEGORIES_ACTIVE=false
MAX_PINNED_POSTS=1
MAX_GROUP_PINNED_POSTS=1

View File

@ -14,6 +14,7 @@ module.exports = {
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:promise/recommended',
'plugin:security/recommended-legacy',
'plugin:@eslint-community/eslint-comments/recommended',
'prettier',
],
@ -174,10 +175,6 @@ module.exports = {
'@eslint-community/eslint-comments/require-description': 'off',
},
overrides: [
{
files: ['*.js', '*.cjs', '*.ts', '*.tsx'],
extends: ['plugin:security/recommended-legacy'],
},
// only for ts files
{
files: ['*.ts', '*.tsx'],
@ -231,33 +228,5 @@ module.exports = {
files: ['*.json', '*.json5', '*.jsonc'],
parser: 'jsonc-eslint-parser',
},
{
files: ['*.graphql', '*.gql'],
parser: '@graphql-eslint/eslint-plugin',
plugins: ['@graphql-eslint'],
extends: ['plugin:@graphql-eslint/schema-recommended'],
rules: {
'@graphql-eslint/description-style': ['error', { style: 'inline' }],
'@graphql-eslint/require-description': 'off',
'@graphql-eslint/naming-convention': 'off',
'@graphql-eslint/strict-id-in-types': 'off',
'@graphql-eslint/no-typename-prefix': 'off',
// incompatible: `depends on a GraphQL validation rule "XXX" but it's not available in the "graphql" version you are using. Skipping…`
'@graphql-eslint/known-directives': 'off',
'@graphql-eslint/known-argument-names': 'off',
'@graphql-eslint/known-type-names': 'off',
'@graphql-eslint/lone-schema-definition': 'off',
'@graphql-eslint/provided-required-arguments': 'off',
'@graphql-eslint/unique-directive-names': 'off',
'@graphql-eslint/unique-directive-names-per-location': 'off',
'@graphql-eslint/unique-field-definition-names': 'off',
'@graphql-eslint/unique-operation-types': 'off',
'@graphql-eslint/unique-type-names': 'off',
},
parserOptions: {
schema: './src/graphql/types/**/*.gql',
assumeValid: true,
},
},
],
}

View File

@ -1 +1 @@
v25.3.0
v24.2.0

1
backend/.tool-versions Normal file
View File

@ -0,0 +1 @@
nodejs 24.2.0

View File

@ -1,5 +1,4 @@
# syntax=docker/dockerfile:1
FROM node:25.6.0-alpine AS base
FROM node:24.8.0-alpine AS base
LABEL org.label-schema.name="ocelot.social:backend"
LABEL org.label-schema.description="Backend of the Social Network Software ocelot.social"
LABEL org.label-schema.usage="https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/README.md"
@ -29,15 +28,13 @@ ONBUILD COPY ./branding/email/ src/middleware/helpers/email/
ONBUILD COPY ./branding/middlewares/ src/middleware/branding/
ONBUILD COPY ./branding/data/ src/db/data
ONBUILD COPY ./branding/public/ public/
ONBUILD RUN --mount=type=cache,target=/yarn-cache,sharing=locked \
yarn install --production=false --frozen-lockfile --non-interactive --cache-folder /yarn-cache
ONBUILD RUN yarn install --production=false --frozen-lockfile --non-interactive
ONBUILD RUN yarn run build
ONBUILD RUN mkdir /build
ONBUILD RUN cp -r ./build /build
ONBUILD RUN cp -r ./public /build
ONBUILD RUN cp -r ./package.json yarn.lock /build
ONBUILD RUN --mount=type=cache,target=/yarn-cache,sharing=locked \
cd /build && yarn install --production=true --frozen-lockfile --non-interactive --cache-folder /yarn-cache
ONBUILD RUN cd /build && yarn install --production=true --frozen-lockfile --non-interactive
FROM build AS test
# required for the migrations

View File

@ -19,16 +19,18 @@ Wait a little until your backend is up and running at [http://localhost:4000/](h
## Installation without Docker
For the local installation you need a recent version of
[Node](https://nodejs.org/en/). We are using
`v25.3.0` and therefore we recommend to use the same version. You can use the
[Node](https://nodejs.org/en/) (>= `v16.19.0`). We are using
`v24.2.0` and therefore we recommend to use the same version
([see](https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4082)
some known problems with more recent node versions). You can use the
[node version manager](https://github.com/nvm-sh/nvm) `nvm` to switch
between different local Node versions:
```sh
# install Node using '.nvmrc' file
# install Node
$ cd backend
$ nvm install
$ nvm use
$ nvm install v24.2.0
$ nvm use v24.2.0
```
Install node dependencies with [yarn](https://yarnpkg.com/en/):

View File

@ -18,7 +18,7 @@ module.exports = {
],
coverageThreshold: {
global: {
lines: 93,
lines: 92,
},
},
testMatch: ['**/src/**/?(*.)+(spec|test).ts?(x)'],

View File

@ -1,6 +1,6 @@
{
"name": "ocelot-social-backend",
"version": "3.14.1",
"version": "3.12.2",
"description": "GraphQL Backend for ocelot.social",
"repository": "https://github.com/Ocelot-Social-Community/Ocelot-Social",
"author": "ocelot.social Community",
@ -12,7 +12,7 @@
"build": "tsc && tsc-alias && ./scripts/build.copy.files.sh",
"dev": "nodemon --exec ts-node --require tsconfig-paths/register src/index.ts -e js,ts,gql",
"dev:debug": "nodemon --exec node --inspect=0.0.0.0:9229 build/src/index.js -e js,ts,gql",
"lint": "eslint --max-warnings=0 --report-unused-disable-directives --ext .js,.ts,.cjs,.json,.json5,.jsonc,.graphql,.gql .",
"lint": "eslint --max-warnings=0 --report-unused-disable-directives --ext .js,.ts,.cjs,.json,.json5,.jsonc .",
"test": "cross-env NODE_ENV=test NODE_OPTIONS=--max-old-space-size=8192 jest --runInBand --coverage --forceExit --detectOpenHandles",
"db:reset": "ts-node --require tsconfig-paths/register src/db/reset.ts",
"db:reset:withmigrations": "ts-node --require tsconfig-paths/register src/db/reset-with-migrations.ts",
@ -23,29 +23,26 @@
"db:data:categories": "ts-node --require tsconfig-paths/register src/db/categories.ts",
"db:migrate": "migrate --compiler 'ts:./src/db/compiler.ts' --migrations-dir ./src/db/migrations --store ./src/db/migrate/store.ts",
"db:migrate:create": "migrate --compiler 'ts:./src/db/compiler.ts' --migrations-dir ./src/db/migrations --template-file ./src/db/migrate/template.ts --date-format 'yyyymmddHHmmss' create",
"db:func:disable:notifications": "ts-node --require tsconfig-paths/register src/db/disable-notifications.ts",
"prod:migrate": "migrate --migrations-dir ./build/src/db/migrations --store ./build/src/db/migrate/store.js",
"prod:db:data:branding": "node build/src/db/data-branding.js",
"prod:db:data:categories": "node build/src/db/categories.js",
"prod:db:data:admin": "node build/src/db/admin.js",
"prod:db:func:disable:notifications": "node build/src/db/disable-notifications.js"
"prod:db:data:categories": "node build/src/db/categories.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.990.0",
"@aws-sdk/lib-storage": "^3.985.0",
"@sentry/node": "^5.30.0",
"@aws-sdk/client-s3": "^3.888.0",
"@aws-sdk/lib-storage": "^3.888.0",
"@sentry/node": "^5.15.4",
"@types/mime-types": "^3.0.1",
"apollo-server": "~2.14.2",
"apollo-server-express": "^2.14.2",
"bcryptjs": "~3.0.3",
"bcryptjs": "~3.0.2",
"body-parser": "^1.20.3",
"cheerio": "~1.2.0",
"cross-env": "~10.1.0",
"cheerio": "~1.1.2",
"cross-env": "~10.0.0",
"dotenv": "~17.0.1",
"email-templates": "^13.0.1",
"express": "^4.22.1",
"email-templates": "^12.0.3",
"express": "^5.1.0",
"graphql": "^14.6.0",
"graphql-middleware": "~6.1.35",
"graphql-middleware": "~4.0.2",
"graphql-middleware-sentry": "^3.2.1",
"graphql-redis-subscriptions": "^2.7.0",
"graphql-shield": "~7.2.2",
@ -53,56 +50,55 @@
"graphql-tag": "~2.10.3",
"graphql-upload": "^13.0.0",
"helmet": "~8.1.0",
"ioredis": "^5.9.2",
"ioredis": "^5.7.0",
"jsonwebtoken": "~8.5.1",
"languagedetect": "^2.0.0",
"linkify-html": "^4.3.2",
"linkifyjs": "^4.3.2",
"lodash": "~4.17.23",
"lodash": "~4.17.21",
"merge-graphql-schemas": "^1.7.8",
"metascraper": "^5.49.19",
"metascraper-author": "^5.49.19",
"metascraper-date": "^5.49.19",
"metascraper-description": "^5.49.19",
"metascraper-image": "^5.49.19",
"metascraper-lang": "^5.49.19",
"metascraper": "^5.49.2",
"metascraper-author": "^5.49.2",
"metascraper-date": "^5.49.2",
"metascraper-description": "^5.49.2",
"metascraper-image": "^5.49.2",
"metascraper-lang": "^5.49.2",
"metascraper-lang-detector": "^4.10.2",
"metascraper-logo": "^5.49.19",
"metascraper-publisher": "^5.49.19",
"metascraper-logo": "^5.49.2",
"metascraper-publisher": "^5.49.2",
"metascraper-soundcloud": "^5.34.4",
"metascraper-title": "^5.49.19",
"metascraper-url": "^5.49.19",
"metascraper-video": "^5.49.19",
"metascraper-youtube": "^5.49.20",
"metascraper-title": "^5.49.2",
"metascraper-url": "^5.49.2",
"metascraper-video": "^5.49.2",
"metascraper-youtube": "^5.49.2",
"migrate": "^2.1.0",
"mime-types": "^3.0.2",
"minimatch": "^10.1.2",
"mime-types": "^3.0.1",
"minimatch": "^10.0.3",
"mustache": "^4.2.0",
"neo4j-driver": "^4.4.11",
"neo4j-graphql-js": "2.11.5",
"neo4j-graphql-js": "^2.11.5",
"neode": "^0.4.9",
"node-fetch": "^2.7.0",
"nodemailer": "^8.0.1",
"nodemailer": "^7.0.6",
"nodemailer-html-to-text": "^3.2.0",
"preview-email": "^3.1.1",
"preview-email": "^3.1.0",
"pug": "^3.0.3",
"sanitize-html": "~2.17.0",
"slugify": "^1.6.6",
"trunc-html": "~1.1.2",
"tslog": "^4.10.2",
"tslog": "^4.9.3",
"uuid": "~9.0.1",
"validator": "^13.15.26",
"validator": "^13.15.15",
"xregexp": "^5.1.2"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
"@eslint-community/eslint-plugin-eslint-comments": "^4.5.0",
"@faker-js/faker": "9.9.0",
"@graphql-eslint/eslint-plugin": "^3.20.1",
"@types/email-templates": "^10.0.4",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "~8.5.1",
"@types/lodash": "^4.17.23",
"@types/node": "^25.2.3",
"@types/lodash": "^4.17.20",
"@types/node": "^24.4.0",
"@types/request": "^2.48.13",
"@types/slug": "^5.0.9",
"@types/uuid": "~9.0.1",
@ -114,19 +110,19 @@
"eslint-config-standard": "^17.1.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^29.13.0",
"eslint-plugin-jsonc": "^2.21.1",
"eslint-plugin-n": "^17.23.2",
"eslint-plugin-jest": "^29.0.1",
"eslint-plugin-jsonc": "^2.20.1",
"eslint-plugin-n": "^17.21.3",
"eslint-plugin-no-catch-all": "^1.1.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-security": "^3.0.1",
"jest": "^30.2.0",
"nodemon": "~3.1.11",
"prettier": "^3.8.1",
"jest": "^30.1.3",
"nodemon": "~3.1.10",
"prettier": "^3.6.2",
"require-json5": "^1.3.0",
"rosie": "^2.1.1",
"ts-jest": "^29.4.6",
"ts-jest": "^29.4.1",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.16",
"tsconfig-paths": "^4.2.0",
@ -137,9 +133,7 @@
"**/graphql-upload": "^11.0.0",
"**/strip-ansi": "6.0.1",
"**/string-width": "4.2.0",
"**/wrap-ansi": "7.0.0",
"**/jwa": "^2.0.1",
"**/@types/express": "4.17.25"
"**/wrap-ansi": "7.0.0"
},
"engines": {
"node": ">=20.12.1"

View File

@ -48,7 +48,7 @@ const SMTP_PASSWORD = env.SMTP_PASSWORD
const SMTP_DKIM_DOMAINNAME = env.SMTP_DKIM_DOMAINNAME
const SMTP_DKIM_KEYSELECTOR = env.SMTP_DKIM_KEYSELECTOR
// PEM format = https://docs.progress.com/bundle/datadirect-hybrid-data-pipeline-installation-46/page/PEM-file-format.html
const SMTP_DKIM_PRIVATEKEY = env.SMTP_DKIM_PRIVATEKEY?.replace(/\\n/g, '\n') // replace all "\n" in .env string by real line break
const SMTP_DKIM_PRIVATKEY = env.SMTP_DKIM_PRIVATKEY?.replace(/\\n/g, '\n') // replace all "\n" in .env string by real line break
const SMTP_MAX_CONNECTIONS = (env.SMTP_MAX_CONNECTIONS && parseInt(env.SMTP_MAX_CONNECTIONS)) || 5
const SMTP_MAX_MESSAGES = (env.SMTP_MAX_MESSAGES && parseInt(env.SMTP_MAX_MESSAGES)) || 100
@ -67,11 +67,11 @@ if (SMTP_USERNAME && SMTP_PASSWORD) {
pass: SMTP_PASSWORD,
}
}
if (SMTP_DKIM_DOMAINNAME && SMTP_DKIM_KEYSELECTOR && SMTP_DKIM_PRIVATEKEY) {
if (SMTP_DKIM_DOMAINNAME && SMTP_DKIM_KEYSELECTOR && SMTP_DKIM_PRIVATKEY) {
nodemailerTransportOptions.dkim = {
domainName: SMTP_DKIM_DOMAINNAME,
keySelector: SMTP_DKIM_KEYSELECTOR,
privateKey: SMTP_DKIM_PRIVATEKEY,
privateKey: SMTP_DKIM_PRIVATKEY,
}
}
@ -138,9 +138,6 @@ const options = {
MAX_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_PINNED_POSTS))
? 1
: Number(process.env.MAX_PINNED_POSTS),
MAX_GROUP_PINNED_POSTS: Number.isNaN(Number(process.env.MAX_GROUP_PINNED_POSTS))
? 1
: Number(process.env.MAX_GROUP_PINNED_POSTS),
}
const language = {

View File

@ -1,61 +0,0 @@
import databaseContext from '@context/database'
const run = async () => {
const args = process.argv.slice(2)
if (args.length !== 1) {
// eslint-disable-next-line no-console
console.error('Usage: yarn run db:func:disable-notifications <email>')
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
const email = args[0]
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
// eslint-disable-next-line no-console
console.error('Error: Invalid email address format')
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
const { write } = databaseContext()
const result = (
await write({
query: `
MATCH (:EmailAddress {email: $email})-[:BELONGS_TO]->(user:User)
SET user.emailNotificationsFollowingUsers = false
SET user.emailNotificationsPostInGroup = false
SET user.emailNotificationsCommentOnObservedPost = false
SET user.emailNotificationsMention = false
SET user.emailNotificationsChatMessage = false
SET user.emailNotificationsGroupMemberJoined = false
SET user.emailNotificationsGroupMemberLeft = false
SET user.emailNotificationsGroupMemberRemoved = false
SET user.emailNotificationsGroupMemberRoleChanged = false
RETURN toString(count(user)) as count
`,
variables: {
email,
},
})
).records[0].get('count') as string
if (result !== '1') {
// eslint-disable-next-line no-console
console.error(`User with email address ${email} not found`)
// eslint-disable-next-line n/no-process-exit
process.exit(1)
}
// eslint-disable-next-line no-console
console.log(`Notifications for User with email address ${email} disabled`)
// eslint-disable-next-line n/no-process-exit
process.exit(0)
}
void (async function () {
await run()
})()

View File

@ -18,7 +18,7 @@ export default {
},
title: { type: 'string', disallow: [null], min: 3 },
slug: { type: 'string', allow: [null], unique: 'true' },
content: { type: 'string', disallow: [null], required: true, min: 3 },
content: { type: 'string', disallow: [null], min: 3 },
contentExcerpt: { type: 'string', allow: [null] },
deleted: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false },
@ -58,7 +58,6 @@ export default {
},
},
pinned: { type: 'boolean', default: null, valid: [null, true] },
groupPinned: { type: 'boolean', default: null, valid: [null, true] },
postType: { type: 'string', default: 'Article', valid: ['Article', 'Event'] },
observes: {
type: 'relationship',

View File

@ -1,49 +1,36 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable n/no-missing-require */
/* eslint-disable n/global-require */
// NOTE: We cannot use `fs` here to clean up the code. Cypress breaks on any npm
// module that is not browser-compatible. Node's `fs` module is server-side only
//
// We use static imports instead of dynamic require() to ensure compatibility
// with both Node.js and Webpack (used by Cypress cucumber preprocessor).
import Badge from './Badge'
import Category from './Category'
import Comment from './Comment'
import Donations from './Donations'
import EmailAddress from './EmailAddress'
import File from './File'
import Group from './Group'
import Image from './Image'
import InviteCode from './InviteCode'
import Location from './Location'
import Migration from './Migration'
import Post from './Post'
import Report from './Report'
import SocialMedia from './SocialMedia'
import Tag from './Tag'
import UnverifiedEmailAddress from './UnverifiedEmailAddress'
import User from './User'
import type Neode from 'neode'
// Type assertion needed because TypeScript infers literal types from the model
// objects (e.g., type: 'string' as literal), but Neode expects the broader
// SchemaObject type with PropertyTypes union. The Neode type definitions are
// incomplete/incorrect, so we use double assertion to bypass the check.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare let Cypress: any | undefined
export default {
Badge,
Category,
Comment,
Donations,
EmailAddress,
File,
Group,
Image,
InviteCode,
Location,
Migration,
Post,
Report,
SocialMedia,
Tag,
UnverifiedEmailAddress,
User,
} as unknown as Record<string, Neode.SchemaObject>
File: typeof Cypress !== 'undefined' ? require('./File') : require('./File').default,
Image: typeof Cypress !== 'undefined' ? require('./Image') : require('./Image').default,
Badge: typeof Cypress !== 'undefined' ? require('./Badge') : require('./Badge').default,
User: typeof Cypress !== 'undefined' ? require('./User') : require('./User').default,
Group: typeof Cypress !== 'undefined' ? require('./Group') : require('./Group').default,
EmailAddress:
typeof Cypress !== 'undefined' ? require('./EmailAddress') : require('./EmailAddress').default,
UnverifiedEmailAddress:
typeof Cypress !== 'undefined'
? require('./UnverifiedEmailAddress')
: require('./UnverifiedEmailAddress').default,
SocialMedia:
typeof Cypress !== 'undefined' ? require('./SocialMedia') : require('./SocialMedia').default,
Post: typeof Cypress !== 'undefined' ? require('./Post') : require('./Post').default,
Comment: typeof Cypress !== 'undefined' ? require('./Comment') : require('./Comment').default,
Category: typeof Cypress !== 'undefined' ? require('./Category') : require('./Category').default,
Tag: typeof Cypress !== 'undefined' ? require('./Tag') : require('./Tag').default,
Location: typeof Cypress !== 'undefined' ? require('./Location') : require('./Location').default,
Donations:
typeof Cypress !== 'undefined' ? require('./Donations') : require('./Donations').default,
Report: typeof Cypress !== 'undefined' ? require('./Report') : require('./Report').default,
Migration:
typeof Cypress !== 'undefined' ? require('./Migration') : require('./Migration').default,
InviteCode:
typeof Cypress !== 'undefined' ? require('./InviteCode') : require('./InviteCode').default,
}

View File

@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import path from 'node:path'
import Email from 'email-templates'
@ -93,8 +94,8 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
: notification?.from?.title,
postUrl: new URL(
notification?.from?.__typename === 'Comment'
? `/post/${encodeURIComponent(notification?.from?.post?.id)}/${encodeURIComponent(notification?.from?.post?.slug)}`
: `/post/${encodeURIComponent(notification?.from?.id)}/${encodeURIComponent(notification?.from?.slug)}`,
? `/post/${notification?.from?.post?.id}/${notification?.from?.post?.slug}`
: `/post/${notification?.from?.id}/${notification?.from?.slug}`,
CONFIG.CLIENT_URI,
),
postAuthorName:
@ -105,7 +106,7 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
notification?.from?.__typename === 'Comment'
? undefined
: new URL(
`profile/${encodeURIComponent(notification?.from?.author?.id)}/${encodeURIComponent(notification?.from?.author?.slug)}`,
`profile/${notification?.from?.author?.id}/${notification?.from?.author?.slug}`,
CONFIG.CLIENT_URI,
),
commenterName:
@ -115,14 +116,14 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
commenterUrl:
notification?.from?.__typename === 'Comment'
? new URL(
`/profile/${encodeURIComponent(notification?.from?.author?.id)}/${encodeURIComponent(notification?.from?.author?.slug)}`,
`/profile/${notification?.from?.author?.id}/${notification?.from?.author?.slug}`,
CONFIG.CLIENT_URI,
)
: undefined,
commentUrl:
notification?.from?.__typename === 'Comment'
? new URL(
`/post/${encodeURIComponent(notification?.from?.post?.id)}/${encodeURIComponent(notification?.from?.post?.slug)}#commentId-${encodeURIComponent(notification?.from?.id)}`,
`/post/${notification?.from?.post?.id}/${notification?.from?.post?.slug}#commentId-${notification?.from?.id}`,
CONFIG.CLIENT_URI,
)
: undefined,
@ -131,7 +132,7 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
groupUrl:
notification?.from?.__typename === 'Group'
? new URL(
`/groups/${encodeURIComponent(notification?.from?.id)}/${encodeURIComponent(notification?.from?.slug)}`,
`/groups/${notification?.from?.id}/${notification?.from?.slug}`,
CONFIG.CLIENT_URI,
)
: undefined,
@ -142,7 +143,7 @@ export const sendNotificationMail = async (notification: any): Promise<OriginalM
groupRelatedUserUrl:
notification?.from?.__typename === 'Group'
? new URL(
`/profile/${encodeURIComponent(notification?.relatedUser?.id)}/${encodeURIComponent(notification?.relatedUser?.slug)}`,
`/profile/${notification?.relatedUser?.id}/${notification?.relatedUser?.slug}`,
CONFIG.CLIENT_URI,
)
: undefined,
@ -176,10 +177,7 @@ export const sendChatMessageMail = async (
locale: recipientUser.locale,
name: recipientUser.name,
chattingUser: senderUser.name,
chattingUserUrl: new URL(
`/profile/${encodeURIComponent(senderUser.id)}/${encodeURIComponent(senderUser.slug)}`,
CONFIG.CLIENT_URI,
),
chattingUserUrl: new URL(`/profile/${senderUser.id}/${senderUser.slug}`, CONFIG.CLIENT_URI),
chatUrl: new URL('/chat', CONFIG.CLIENT_URI),
},
})

View File

@ -3,14 +3,10 @@ import gql from 'graphql-tag'
export const ChangeGroupMemberRole = gql`
mutation ($groupId: ID!, $userId: ID!, $roleInGroup: GroupMemberRole!) {
ChangeGroupMemberRole(groupId: $groupId, userId: $userId, roleInGroup: $roleInGroup) {
user {
id
name
slug
}
membership {
role
}
id
name
slug
myRoleInGroup
}
}
`

View File

@ -8,8 +8,6 @@ export const CreateComment = gql`
author {
name
}
isPostObservedByMe
postObservingUsersCount
}
}
`

View File

@ -45,7 +45,6 @@ export const CreatePost = gql`
}
isObservedByMe
observingUsersCount
language
}
}
`

View File

@ -1,14 +0,0 @@
import gql from 'graphql-tag'
export const CreateSocialMedia = gql`
mutation ($url: String!) {
CreateSocialMedia(url: $url) {
id
url
url
ownedBy {
name
}
}
}
`

View File

@ -3,14 +3,10 @@ import gql from 'graphql-tag'
export const GroupMembers = gql`
query GroupMembers($id: ID!) {
GroupMembers(id: $id) {
user {
id
name
slug
}
membership {
role
}
id
name
slug
myRoleInGroup
}
}
`

View File

@ -3,14 +3,10 @@ import gql from 'graphql-tag'
export const JoinGroup = gql`
mutation ($groupId: ID!, $userId: ID!) {
JoinGroup(groupId: $groupId, userId: $userId) {
user {
id
name
slug
}
membership {
role
}
id
name
slug
myRoleInGroup
}
}
`

View File

@ -3,14 +3,10 @@ import gql from 'graphql-tag'
export const LeaveGroup = gql`
mutation ($groupId: ID!, $userId: ID!) {
LeaveGroup(groupId: $groupId, userId: $userId) {
user {
id
name
slug
}
membership {
role
}
id
name
slug
myRoleInGroup
}
}
`

View File

@ -6,34 +6,10 @@ export const Post = gql`
id
title
content
contentExcerpt
eventStart
pinned
createdAt
pinnedAt
isObservedByMe
observingUsersCount
clickedCount
emotionsCount
emotions {
emotion
User {
id
}
}
author {
id
name
}
shoutedBy {
id
}
tags {
id
}
comments {
content
}
}
}
`

View File

@ -3,14 +3,10 @@ import gql from 'graphql-tag'
export const RemoveUserFromGroup = gql`
mutation ($groupId: ID!, $userId: ID!) {
RemoveUserFromGroup(groupId: $groupId, userId: $userId) {
user {
id
name
slug
}
membership {
role
}
id
name
slug
myRoleInGroup
}
}
`

View File

@ -8,8 +8,6 @@ export const SignupVerification = gql`
$slug: String
$nonce: String!
$termsAndConditionsAgreedVersion: String!
$about: String
$locale: String
) {
SignupVerification(
email: $email
@ -18,13 +16,9 @@ export const SignupVerification = gql`
slug: $slug
nonce: $nonce
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
about: $about
locale: $locale
) {
id
slug
termsAndConditionsAgreedVersion
termsAndConditionsAgreedAt
}
}
`

View File

@ -1,44 +1,10 @@
import gql from 'graphql-tag'
export const UpdatePost = gql`
mutation (
$id: ID!
$title: String!
$content: String!
$image: ImageInput
$categoryIds: [ID]
$postType: PostType
$eventInput: _EventInput
) {
UpdatePost(
id: $id
title: $title
content: $content
image: $image
categoryIds: $categoryIds
postType: $postType
eventInput: $eventInput
) {
id
mutation ($id: ID!, $title: String!, $postContent: String!, $categoryIds: [ID]!) {
UpdatePost(id: $id, content: $postContent, title: $title, categoryIds: $categoryIds) {
title
content
author {
name
slug
}
createdAt
updatedAt
categories {
id
}
postType
eventStart
eventLocationName
eventVenue
eventLocation {
lng
lat
}
}
}
`

View File

@ -1,38 +1,9 @@
import gql from 'graphql-tag'
export const UpdateUser = gql`
mutation (
$id: ID!
$name: String
$termsAndConditionsAgreedVersion: String
$locationName: String # empty string '' sets it to null
$emailNotificationSettings: [EmailNotificationSettingsInput]
) {
UpdateUser(
id: $id
name: $name
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locationName: $locationName
emailNotificationSettings: $emailNotificationSettings
) {
id
mutation ($id: ID!, $name: String) {
UpdateUser(id: $id, name: $name) {
name
termsAndConditionsAgreedVersion
termsAndConditionsAgreedAt
locationName
location {
name
nameDE
nameEN
nameRU
}
emailNotificationSettings {
type
settings {
name
value
}
}
}
}
`

View File

@ -1,165 +1,9 @@
import gql from 'graphql-tag'
export const User = gql`
query ($id: ID, $name: String, $email: String) {
User(id: $id, name: $name, email: $email) {
id
name
badgeTrophiesCount
badgeTrophies {
id
}
badgeVerification {
id
isDefault
}
badgeTrophiesSelected {
id
isDefault
}
followedBy {
id
}
followedByCurrentUser
following {
name
slug
about
avatar {
url
}
comments {
content
contentExcerpt
}
contributions {
title
slug
image {
url
}
content
contentExcerpt
}
}
isMuted
isBlocked
location {
distanceToMe
}
activeCategories
}
}
`
export const UserEmailNotificationSettings = gql`
query ($id: ID, $name: String, $email: String) {
User(id: $id, name: $name, email: $email) {
id
name
badgeTrophiesCount
badgeTrophies {
id
}
badgeVerification {
id
isDefault
}
badgeTrophiesSelected {
id
isDefault
}
followedBy {
id
}
followedByCurrentUser
following {
name
slug
about
avatar {
url
}
comments {
content
contentExcerpt
}
contributions {
title
slug
image {
url
}
content
contentExcerpt
}
}
isMuted
isBlocked
location {
distanceToMe
}
emailNotificationSettings {
type
settings {
name
value
}
}
activeCategories
}
}
`
export const UserEmail = gql`
query ($id: ID, $name: String, $email: String) {
User(id: $id, name: $name, email: $email) {
id
name
query ($name: String) {
User(name: $name) {
email
badgeTrophiesCount
badgeTrophies {
id
}
badgeVerification {
id
isDefault
}
badgeTrophiesSelected {
id
isDefault
}
followedBy {
id
}
followedByCurrentUser
following {
name
slug
about
avatar {
url
}
comments {
content
contentExcerpt
}
contributions {
title
slug
image {
url
}
content
contentExcerpt
}
}
isMuted
isBlocked
location {
distanceToMe
}
activeCategories
}
}
`

View File

@ -1,7 +0,0 @@
import gql from 'graphql-tag'
export const availableRoles = gql`
query {
availableRoles
}
`

View File

@ -3,15 +3,6 @@ import gql from 'graphql-tag'
export const currentUser = gql`
query currentUser {
currentUser {
id
slug
name
avatar {
url
}
email
role
activeCategories
following {
name
}

View File

@ -3,7 +3,6 @@ import gql from 'graphql-tag'
export const followUser = gql`
mutation ($id: ID!) {
followUser(id: $id) {
id
name
followedBy {
id

View File

@ -3,7 +3,6 @@ import gql from 'graphql-tag'
export const markAllAsRead = gql`
mutation {
markAllAsRead {
id
from {
__typename
... on Post {

View File

@ -3,23 +3,14 @@ import gql from 'graphql-tag'
export const notifications = gql`
query ($read: Boolean, $orderBy: NotificationOrdering) {
notifications(read: $read, orderBy: $orderBy) {
reason
relatedUser {
id
}
from {
__typename
... on Post {
id
content
}
... on Comment {
id
content
}
... on Group {
id
}
}
read
createdAt

View File

@ -1,25 +0,0 @@
import gql from 'graphql-tag'
export const pinGroupPost = gql`
mutation ($id: ID!) {
pinGroupPost(id: $id) {
id
title
content
author {
name
slug
}
pinnedBy {
id
name
role
}
createdAt
updatedAt
pinnedAt
pinned
groupPinned
}
}
`

View File

@ -11,7 +11,6 @@ export const profilePagePosts = gql`
id
title
content
groupPinned
}
}
`

View File

@ -1,20 +1,8 @@
import gql from 'graphql-tag'
export const reports = gql`
query (
$orderBy: ReportOrdering
$reviewed: Boolean
$closed: Boolean
$first: Int
$offset: Int
) {
reports(
orderBy: $orderBy
reviewed: $reviewed
closed: $closed
first: $first
offset: $offset
) {
query ($closed: Boolean) {
reports(orderBy: createdAt_desc, closed: $closed) {
id
createdAt
updatedAt

View File

@ -5,7 +5,6 @@ export const searchPosts = gql`
searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) {
postCount
posts {
__typename
id
title
content

View File

@ -1,25 +0,0 @@
import gql from 'graphql-tag'
export const unpinGroupPost = gql`
mutation ($id: ID!) {
unpinGroupPost(id: $id) {
id
title
content
author {
name
slug
}
pinnedBy {
id
name
role
}
createdAt
updatedAt
pinned
pinnedAt
groupPinned
}
}
`

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { GraphQLUpload } from 'graphql-upload'
export default {

View File

@ -130,13 +130,10 @@ export const attachments = (config: S3Config) => {
const { upload } = fileInput
if (!upload) throw new UserInputError('Cannot find attachment for given resource')
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const uploadFile = await upload
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
const { name: fileName, ext } = path.parse(uploadFile.filename)
const uniqueFilename = `${uuid()}-${slug(fileName)}${ext}`
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const url = await s3.uploadFile({
...uploadFile,
uniqueFilename,

View File

@ -1,13 +1,14 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import gql from 'graphql-tag'
import { TROPHY_BADGES_SELECTED_MAX } from '@constants/badges'
import Factory, { cleanDatabase } from '@db/factories'
import { revokeBadge } from '@graphql/queries/revokeBadge'
import { rewardTrophyBadge } from '@graphql/queries/rewardTrophyBadge'
import { setTrophyBadgeSelected } from '@graphql/queries/setTrophyBadgeSelected'
import { setVerificationBadge } from '@graphql/queries/setVerificationBadge'
import { User } from '@graphql/queries/User'
import type { ApolloTestSetup } from '@root/test/helpers'
import { createApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
@ -799,6 +800,24 @@ describe('Badges', () => {
describe('check test setup', () => {
it('user has one badge and has it selected', async () => {
authenticatedUser = await regularUser.toJson()
const userQuery = gql`
{
User(id: "regular-user-id") {
badgeTrophiesCount
badgeTrophies {
id
}
badgeVerification {
id
isDefault
}
badgeTrophiesSelected {
id
isDefault
}
}
}
`
const expected = {
data: {
User: [
@ -852,9 +871,7 @@ describe('Badges', () => {
},
errors: undefined,
}
await expect(
query({ query: User, variables: { id: 'regular-user-id' } }),
).resolves.toMatchObject(expected)
await expect(query({ query: userQuery })).resolves.toMatchObject(expected)
})
})

View File

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import fs from 'node:fs'
import path from 'node:path'

View File

@ -2,39 +2,59 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import Factory, { cleanDatabase } from '@db/factories'
import { getDriver, getNeode } from '@db/neo4j'
import { followUser } from '@graphql/queries/followUser'
import { unfollowUser } from '@graphql/queries/unfollowUser'
import { User } from '@graphql/queries/User'
import { createApolloTestSetup } from '@root/test/helpers'
import type { ApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
import createServer from '@src/server'
let authenticatedUser: Context['user']
const context = () => ({ authenticatedUser })
let mutate: ApolloTestSetup['mutate']
let query: ApolloTestSetup['query']
let database: ApolloTestSetup['database']
let server: ApolloTestSetup['server']
const driver = getDriver()
const neode = getNeode()
let query
let mutate
let authenticatedUser
let user1
let user2
let variables
const userQuery = gql`
query ($id: ID) {
User(id: $id) {
followedBy {
id
}
followedByCurrentUser
}
}
`
beforeAll(async () => {
await cleanDatabase()
const apolloSetup = createApolloTestSetup({ context })
mutate = apolloSetup.mutate
query = apolloSetup.query
database = apolloSetup.database
server = apolloSetup.server
const { server } = createServer({
context: () => ({
driver,
neode,
user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
}),
})
const testClient = createTestClient(server)
query = testClient.query
mutate = testClient.mutate
})
afterAll(async () => {
await cleanDatabase()
void server.stop()
void database.driver.close()
database.neode.close()
await driver.close()
})
beforeEach(async () => {
@ -109,7 +129,7 @@ describe('follow', () => {
mutation: followUser,
variables,
})
const relation = await database.neode.cypher(
const relation = await neode.cypher(
'MATCH (user:User {id: $id})-[relationship:FOLLOWS]->(followed:User) WHERE relationship.createdAt IS NOT NULL RETURN relationship',
{ id: 'u1' },
)
@ -132,7 +152,7 @@ describe('follow', () => {
}
await expect(
query({
query: User,
query: userQuery,
variables: { id: user1.id },
}),
).resolves.toMatchObject({

View File

@ -891,12 +891,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
JoinGroup: {
user: {
id: 'owner-of-closed-group',
},
membership: {
role: 'usual',
},
id: 'owner-of-closed-group',
myRoleInGroup: 'usual',
},
},
errors: undefined,
@ -918,12 +914,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
JoinGroup: {
user: {
id: 'current-user',
},
membership: {
role: 'owner',
},
id: 'current-user',
myRoleInGroup: 'owner',
},
},
errors: undefined,
@ -947,12 +939,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
JoinGroup: {
user: {
id: 'current-user',
},
membership: {
role: 'pending',
},
id: 'current-user',
myRoleInGroup: 'pending',
},
},
errors: undefined,
@ -974,12 +962,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
JoinGroup: {
user: {
id: 'owner-of-closed-group',
},
membership: {
role: 'owner',
},
id: 'owner-of-closed-group',
myRoleInGroup: 'owner',
},
},
errors: undefined,
@ -1017,12 +1001,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
JoinGroup: {
user: {
id: 'owner-of-hidden-group',
},
membership: {
role: 'owner',
},
id: 'owner-of-hidden-group',
myRoleInGroup: 'owner',
},
},
errors: undefined,
@ -1228,28 +1208,16 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'owner',
}),
id: 'current-user',
myRoleInGroup: 'owner',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'owner-of-closed-group',
myRoleInGroup: 'usual',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'owner-of-hidden-group',
myRoleInGroup: 'usual',
}),
]),
},
@ -1273,28 +1241,16 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'owner',
}),
id: 'current-user',
myRoleInGroup: 'owner',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'owner-of-closed-group',
myRoleInGroup: 'usual',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'owner-of-hidden-group',
myRoleInGroup: 'usual',
}),
]),
},
@ -1318,28 +1274,16 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'owner',
}),
id: 'current-user',
myRoleInGroup: 'owner',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'owner-of-closed-group',
myRoleInGroup: 'usual',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'owner-of-hidden-group',
myRoleInGroup: 'usual',
}),
]),
},
@ -1373,28 +1317,16 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'pending',
}),
id: 'current-user',
myRoleInGroup: 'pending',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'owner',
}),
id: 'owner-of-closed-group',
myRoleInGroup: 'owner',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'owner-of-hidden-group',
myRoleInGroup: 'usual',
}),
]),
},
@ -1418,28 +1350,16 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'pending',
}),
id: 'current-user',
myRoleInGroup: 'pending',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'owner',
}),
id: 'owner-of-closed-group',
myRoleInGroup: 'owner',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'owner-of-hidden-group',
myRoleInGroup: 'usual',
}),
]),
},
@ -1495,36 +1415,20 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
user: expect.objectContaining({
id: 'pending-user',
}),
membership: expect.objectContaining({
role: 'pending',
}),
id: 'pending-user',
myRoleInGroup: 'pending',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'current-user',
myRoleInGroup: 'usual',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'admin',
}),
id: 'owner-of-closed-group',
myRoleInGroup: 'admin',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'owner',
}),
id: 'owner-of-hidden-group',
myRoleInGroup: 'owner',
}),
]),
},
@ -1548,36 +1452,20 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
user: expect.objectContaining({
id: 'pending-user',
}),
membership: expect.objectContaining({
role: 'pending',
}),
id: 'pending-user',
myRoleInGroup: 'pending',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'current-user',
myRoleInGroup: 'usual',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'admin',
}),
id: 'owner-of-closed-group',
myRoleInGroup: 'admin',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'owner',
}),
id: 'owner-of-hidden-group',
myRoleInGroup: 'owner',
}),
]),
},
@ -1601,36 +1489,20 @@ describe('in mode', () => {
data: {
GroupMembers: expect.arrayContaining([
expect.objectContaining({
user: expect.objectContaining({
id: 'pending-user',
}),
membership: expect.objectContaining({
role: 'pending',
}),
id: 'pending-user',
myRoleInGroup: 'pending',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'current-user',
}),
membership: expect.objectContaining({
role: 'usual',
}),
id: 'current-user',
myRoleInGroup: 'usual',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-closed-group',
}),
membership: expect.objectContaining({
role: 'admin',
}),
id: 'owner-of-closed-group',
myRoleInGroup: 'admin',
}),
expect.objectContaining({
user: expect.objectContaining({
id: 'owner-of-hidden-group',
}),
membership: expect.objectContaining({
role: 'owner',
}),
id: 'owner-of-hidden-group',
myRoleInGroup: 'owner',
}),
]),
},
@ -1728,12 +1600,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
user: {
id: 'usual-member-user',
},
membership: {
role: 'usual',
},
id: 'usual-member-user',
myRoleInGroup: 'usual',
},
},
errors: undefined,
@ -1770,12 +1638,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
user: {
id: 'admin-member-user',
},
membership: {
role: 'admin',
},
id: 'admin-member-user',
myRoleInGroup: 'admin',
},
},
errors: undefined,
@ -1809,12 +1673,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
user: {
id: 'second-owner-member-user',
},
membership: {
role: 'owner',
},
id: 'second-owner-member-user',
myRoleInGroup: 'owner',
},
},
errors: undefined,
@ -1899,12 +1759,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
user: {
id: 'owner-member-user',
},
membership: {
role: 'owner',
},
id: 'owner-member-user',
myRoleInGroup: 'owner',
},
},
errors: undefined,
@ -2013,12 +1869,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
user: {
id: 'admin-member-user',
},
membership: {
role: 'owner',
},
id: 'admin-member-user',
myRoleInGroup: 'owner',
},
},
errors: undefined,
@ -2195,12 +2047,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
user: {
id: 'usual-member-user',
},
membership: {
role: 'admin',
},
id: 'usual-member-user',
myRoleInGroup: 'admin',
},
},
errors: undefined,
@ -2225,12 +2073,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
user: {
id: 'usual-member-user',
},
membership: {
role: 'usual',
},
id: 'usual-member-user',
myRoleInGroup: 'usual',
},
},
errors: undefined,
@ -2390,12 +2234,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
user: {
id: 'pending-member-user',
},
membership: {
role: 'usual',
},
id: 'pending-member-user',
myRoleInGroup: 'usual',
},
},
errors: undefined,
@ -2420,12 +2260,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
ChangeGroupMemberRole: {
user: {
id: 'pending-member-user',
},
membership: {
role: 'pending',
},
id: 'pending-member-user',
myRoleInGroup: 'pending',
},
},
errors: undefined,
@ -2577,7 +2413,7 @@ describe('in mode', () => {
},
})
return result.data?.GroupMembers
? !!result.data.GroupMembers.find((member) => member.user.id === userId)
? !!result.data.GroupMembers.find((member) => member.id === userId)
: null
}
@ -2604,10 +2440,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
LeaveGroup: {
user: {
id: 'pending-member-user',
},
membership: null,
id: 'pending-member-user',
myRoleInGroup: null,
},
},
errors: undefined,
@ -2633,10 +2467,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
LeaveGroup: {
user: {
id: 'usual-member-user',
},
membership: null,
id: 'usual-member-user',
myRoleInGroup: null,
},
},
errors: undefined,
@ -2662,10 +2494,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
LeaveGroup: {
user: {
id: 'admin-member-user',
},
membership: null,
id: 'admin-member-user',
myRoleInGroup: null,
},
},
errors: undefined,
@ -3191,10 +3021,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
RemoveUserFromGroup: expect.objectContaining({
user: expect.objectContaining({
id: 'usual-member-user',
}),
membership: null,
id: 'usual-member-user',
myRoleInGroup: null,
}),
},
errors: undefined,
@ -3265,10 +3093,8 @@ describe('in mode', () => {
).resolves.toMatchObject({
data: {
RemoveUserFromGroup: expect.objectContaining({
user: {
id: 'usual-member-user',
},
membership: null,
id: 'usual-member-user',
myRoleInGroup: null,
}),
},
errors: undefined,

View File

@ -24,6 +24,9 @@ export default {
Query: {
Group: async (_object, params, context: Context, _resolveInfo) => {
const { isMember, id, slug, first, offset } = params
let pagination = ''
const orderBy = 'ORDER BY group.createdAt DESC'
if (first !== undefined && offset !== undefined) pagination = `SKIP ${offset} LIMIT ${first}`
const matchParams = { id, slug }
removeUndefinedNullValuesFromObject(matchParams)
const session = context.driver.session()
@ -31,22 +34,43 @@ export default {
if (!context.user) {
throw new Error('Missing authenticated user.')
}
const transactionResponse = await txc.run(
const groupMatchParamsCypher = convertObjectToCypherMapLiteral(matchParams, true)
let groupCypher
if (isMember === true) {
groupCypher = `
MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group:Group${groupMatchParamsCypher})
WITH group, membership
WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
RETURN group {.*, myRole: membership.role}
${orderBy}
${pagination}
`
MATCH (group:Group${convertObjectToCypherMapLiteral(matchParams, true)})
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
WITH group, membership
${(isMember === true && "WHERE membership IS NOT NULL AND (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])") || ''}
${(isMember === false && "WHERE membership IS NULL AND (group.groupType IN ['public', 'closed'])") || ''}
${(isMember === undefined && "WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])") || ''}
RETURN group {.*, myRole: membership.role}
ORDER BY group.createdAt DESC
${first !== undefined && offset !== undefined ? `SKIP ${offset} LIMIT ${first}` : ''}
`,
{
userId: context.user.id,
},
)
} else {
if (isMember === false) {
groupCypher = `
MATCH (group:Group${groupMatchParamsCypher})
WHERE (NOT (:User {id: $userId})-[:MEMBER_OF]->(group))
WITH group
WHERE group.groupType IN ['public', 'closed']
RETURN group {.*, myRole: NULL}
${orderBy}
${pagination}
`
} else {
groupCypher = `
MATCH (group:Group${groupMatchParamsCypher})
OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group)
WITH group, membership
WHERE (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])
RETURN group {.*, myRole: membership.role}
${orderBy}
${pagination}
`
}
}
const transactionResponse = await txc.run(groupCypher, {
userId: context.user.id,
})
return transactionResponse.records.map((record) => record.get('group'))
})
try {
@ -63,7 +87,7 @@ export default {
const readTxResultPromise = session.readTransaction(async (txc) => {
const groupMemberCypher = `
MATCH (user:User)-[membership:MEMBER_OF]->(:Group {id: $groupId})
RETURN user {.*}, membership {.*}
RETURN user {.*, myRoleInGroup: membership.role}
SKIP toInteger($offset) LIMIT toInteger($first)
`
const transactionResponse = await txc.run(groupMemberCypher, {
@ -71,9 +95,7 @@ export default {
first,
offset,
})
return transactionResponse.records.map((record) => {
return { user: record.get('user'), membership: record.get('membership') }
})
return transactionResponse.records.map((record) => record.get('user'))
})
try {
return await readTxResultPromise
@ -275,8 +297,8 @@ export default {
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
const joinGroupCypher = `
MATCH (user:User {id: $userId}), (group:Group {id: $groupId})
MERGE (user)-[membership:MEMBER_OF]->(group)
MATCH (member:User {id: $userId}), (group:Group {id: $groupId})
MERGE (member)-[membership:MEMBER_OF]->(group)
ON CREATE SET
membership.createdAt = toString(datetime()),
membership.updatedAt = null,
@ -285,15 +307,14 @@ export default {
THEN 'usual'
ELSE 'pending'
END
RETURN user {.*}, membership {.*}
RETURN member {.*, myRoleInGroup: membership.role}
`
const transactionResponse = await transaction.run(joinGroupCypher, { groupId, userId })
return transactionResponse.records.map((record) => {
return { user: record.get('user'), membership: record.get('membership') }
})
const [member] = transactionResponse.records.map((record) => record.get('member'))
return member
})
try {
return (await writeTxResultPromise)[0]
return await writeTxResultPromise
} catch (error) {
throw new Error(error)
} finally {
@ -340,7 +361,7 @@ export default {
membership.updatedAt = toString(datetime()),
membership.role = $roleInGroup
${postRestrictionCypher}
RETURN member {.*} as user, membership {.*}
RETURN member {.*, myRoleInGroup: membership.role}
`
const transactionResponse = await transaction.run(joinGroupCypher, {
@ -348,9 +369,7 @@ export default {
userId,
roleInGroup,
})
const [member] = transactionResponse.records.map((record) => {
return { user: record.get('user'), membership: record.get('membership') }
})
const [member] = transactionResponse.records.map((record) => record.get('member'))
return member
})
try {
@ -441,23 +460,6 @@ export default {
},
},
Group: {
myRole: async (parent, _args, context: Context, _resolveInfo) => {
if (!parent.id) {
throw new Error('Can not identify selected Group!')
}
return (
await context.database.query({
query: `
MATCH (:User {id: $user.id})-[membership:MEMBER_OF]->(group:Group {id: $parent.id})
RETURN membership.role as role
`,
variables: {
user: context.user,
parent,
},
})
).records.map((r) => r.get('role'))[0]
},
inviteCodes: async (parent, _args, context: Context, _resolveInfo) => {
if (!parent.id) {
throw new Error('Can not identify selected Group!')
@ -476,18 +478,6 @@ export default {
})
).records.map((r) => r.get('inviteCodes'))
},
currentlyPinnedPostsCount: async (parent, _args, context: Context, _resolveInfo) => {
if (!parent.id) {
throw new Error('Can not identify selected Group!')
}
const result = await context.database.query({
query: `
MATCH (:User)-[pinned:GROUP_PINNED]->(pinnedPosts:Post)-[:IN]->(:Group {id: $group.id})
RETURN toString(count(pinnedPosts)) as count`,
variables: { group: parent },
})
return result.records[0].get('count')
},
...Resolver('Group', {
undefinedToNull: ['deleted', 'disabled', 'locationName', 'about'],
hasMany: {
@ -533,16 +523,14 @@ const removeUserFromGroupWriteTxResultPromise = async (session, groupId, userId)
WITH user, collect(p) AS posts
FOREACH (post IN posts |
MERGE (user)-[:CANNOT_SEE]->(post))
RETURN user {.*}, NULL as membership
RETURN user {.*, myRoleInGroup: NULL}
`
const transactionResponse = await transaction.run(removeUserFromGroupCypher, {
groupId,
userId,
})
const [user] = await transactionResponse.records.map((record) => {
return { user: record.get('user'), membership: record.get('membership') }
})
const [user] = await transactionResponse.records.map((record) => record.get('user'))
return user
})
}

View File

@ -8,8 +8,6 @@ import { getMutedUsers } from '@graphql/resolvers/users'
export const filterForMutedUsers = async (params, context) => {
if (!context.user) return params
// Skip mute filter for single post lookups (direct navigation by id or slug)
if (params.id || params.slug) return params
const [mutedUsers] = await Promise.all([getMutedUsers(context)])
const mutedUsersIds = [...mutedUsers.map((user) => user.id)]
if (!mutedUsersIds.length) return params

View File

@ -84,12 +84,9 @@ export const images = (config: S3Config) => {
const uploadImageFile = async (uploadPromise: Promise<FileUpload> | undefined) => {
if (!uploadPromise) return undefined
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const upload = await uploadPromise
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
const { name, ext } = path.parse(upload.filename)
const uniqueFilename = `${uuid()}-${slug(name)}${ext}`
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return await s3.uploadFile({ ...upload, uniqueFilename })
}

View File

@ -1089,24 +1089,16 @@ describe('redeemInviteCode', () => {
data: {
GroupMembers: expect.arrayContaining([
{
user: {
id: 'inviting-user',
name: 'Inviting User',
slug: 'inviting-user',
},
membership: {
role: 'owner',
},
id: 'inviting-user',
myRoleInGroup: 'owner',
name: 'Inviting User',
slug: 'inviting-user',
},
{
user: {
id: 'other-user',
name: 'Other User',
slug: 'other-user',
},
membership: {
role: 'pending',
},
id: 'other-user',
myRoleInGroup: 'pending',
name: 'Other User',
slug: 'other-user',
},
]),
},

View File

@ -1,34 +1,37 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import Factory, { cleanDatabase } from '@db/factories'
import { UpdateUser } from '@graphql/queries/UpdateUser'
import { User } from '@graphql/queries/User'
import { createApolloTestSetup } from '@root/test/helpers'
import type { ApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
let authenticatedUser: Context['user']
const context = () => ({ authenticatedUser })
let mutate: ApolloTestSetup['mutate']
let query: ApolloTestSetup['query']
let database: ApolloTestSetup['database']
let server: ApolloTestSetup['server']
import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import createServer from '@src/server'
let query, mutate, authenticatedUser
const driver = getDriver()
const neode = getNeode()
beforeAll(async () => {
await cleanDatabase()
const apolloSetup = createApolloTestSetup({ context })
mutate = apolloSetup.mutate
query = apolloSetup.query
database = apolloSetup.database
server = apolloSetup.server
const { server } = createServer({
context: () => {
return {
driver,
neode,
user: authenticatedUser,
}
},
})
query = createTestClient(server).query
mutate = createTestClient(server).mutate
})
afterAll(async () => {
await cleanDatabase()
void server.stop()
void database.driver.close()
database.neode.close()
await driver.close()
})
// TODO: avoid database clean after each test in the future if possible for performance and flakyness reasons by filling the database step by step, see issue https://github.com/Ocelot-Social-Community/Ocelot-Social/issues/4543
@ -40,6 +43,17 @@ describe('resolvers', () => {
describe('Location', () => {
describe('custom mutation, not handled by neo4j-graphql-js', () => {
let variables
const updateUserMutation = gql`
mutation ($id: ID!, $name: String) {
UpdateUser(id: $id, name: $name) {
name
location {
name: nameRU
nameEN
}
}
}
`
beforeEach(async () => {
variables = {
@ -64,12 +78,12 @@ describe('resolvers', () => {
})
it('returns `null` if location translation is not available', async () => {
await expect(mutate({ mutation: UpdateUser, variables })).resolves.toMatchObject({
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: {
UpdateUser: {
name: 'John Doughnut',
location: {
nameRU: null,
name: null,
nameEN: 'Paris',
},
},
@ -81,6 +95,15 @@ describe('resolvers', () => {
})
})
const distanceToMeQuery = gql`
query ($id: ID!) {
User(id: $id) {
location {
distanceToMe
}
}
}
`
let user, myPlaceUser, otherPlaceUser, noCordsPlaceUser, noPlaceUser
describe('distanceToMe', () => {
@ -168,19 +191,21 @@ describe('distanceToMe', () => {
authenticatedUser = await user.toJson()
const targetUser = await user.toJson()
await expect(
query({ query: User, variables: { id: targetUser.id } }),
).resolves.toMatchObject({
data: {
User: [
expect.objectContaining({
location: {
distanceToMe: 0,
query({ query: distanceToMeQuery, variables: { id: targetUser.id } }),
).resolves.toEqual(
expect.objectContaining({
data: {
User: [
{
location: {
distanceToMe: 0,
},
},
}),
],
},
errors: undefined,
})
],
},
errors: undefined,
}),
)
})
})
@ -189,19 +214,21 @@ describe('distanceToMe', () => {
authenticatedUser = await user.toJson()
const targetUser = await myPlaceUser.toJson()
await expect(
query({ query: User, variables: { id: targetUser.id } }),
).resolves.toMatchObject({
data: {
User: [
expect.objectContaining({
location: {
distanceToMe: 0,
query({ query: distanceToMeQuery, variables: { id: targetUser.id } }),
).resolves.toEqual(
expect.objectContaining({
data: {
User: [
{
location: {
distanceToMe: 0,
},
},
}),
],
},
errors: undefined,
})
],
},
errors: undefined,
}),
)
})
})
@ -210,19 +237,21 @@ describe('distanceToMe', () => {
authenticatedUser = await user.toJson()
const targetUser = await otherPlaceUser.toJson()
await expect(
query({ query: User, variables: { id: targetUser.id } }),
).resolves.toMatchObject({
data: {
User: [
expect.objectContaining({
location: {
distanceToMe: 746,
query({ query: distanceToMeQuery, variables: { id: targetUser.id } }),
).resolves.toEqual(
expect.objectContaining({
data: {
User: [
{
location: {
distanceToMe: 746,
},
},
}),
],
},
errors: undefined,
})
],
},
errors: undefined,
}),
)
})
})
@ -231,19 +260,21 @@ describe('distanceToMe', () => {
authenticatedUser = await user.toJson()
const targetUser = await noCordsPlaceUser.toJson()
await expect(
query({ query: User, variables: { id: targetUser.id } }),
).resolves.toMatchObject({
data: {
User: [
expect.objectContaining({
location: {
distanceToMe: null,
query({ query: distanceToMeQuery, variables: { id: targetUser.id } }),
).resolves.toEqual(
expect.objectContaining({
data: {
User: [
{
location: {
distanceToMe: null,
},
},
}),
],
},
errors: undefined,
})
],
},
errors: undefined,
}),
)
})
})
@ -252,17 +283,19 @@ describe('distanceToMe', () => {
authenticatedUser = await user.toJson()
const targetUser = await noPlaceUser.toJson()
await expect(
query({ query: User, variables: { id: targetUser.id } }),
).resolves.toMatchObject({
data: {
User: [
expect.objectContaining({
location: null,
}),
],
},
errors: undefined,
})
query({ query: distanceToMeQuery, variables: { id: targetUser.id } }),
).resolves.toEqual(
expect.objectContaining({
data: {
User: [
{
location: null,
},
],
},
errors: undefined,
}),
)
})
})
})

View File

@ -165,46 +165,44 @@ describe('given some notifications', () => {
describe('no filters', () => {
it('returns all notifications of current user', async () => {
const expected = [
{
from: {
__typename: 'Comment',
content: 'You have seen this comment mentioning already',
},
read: true,
createdAt: '2019-08-30T15:33:48.651Z',
},
{
from: {
__typename: 'Post',
content: 'Already seen post mention',
},
read: true,
createdAt: '2019-08-30T17:33:48.651Z',
},
{
from: {
__typename: 'Comment',
content: 'You have been mentioned in a comment',
},
read: false,
createdAt: '2019-08-30T19:33:48.651Z',
},
{
from: {
__typename: 'Post',
content: 'You have been mentioned in a post',
},
read: false,
createdAt: '2019-08-31T17:33:48.651Z',
},
]
await expect(query({ query: notifications, variables })).resolves.toMatchObject({
data: {
notifications: expect.arrayContaining([
expect.objectContaining({
from: {
__typename: 'Comment',
content: 'You have seen this comment mentioning already',
id: 'c1',
},
read: true,
createdAt: '2019-08-30T15:33:48.651Z',
}),
expect.objectContaining({
from: {
__typename: 'Post',
content: 'Already seen post mention',
id: 'p2',
},
read: true,
createdAt: '2019-08-30T17:33:48.651Z',
}),
expect.objectContaining({
from: {
__typename: 'Comment',
content: 'You have been mentioned in a comment',
id: 'c2',
},
read: false,
createdAt: '2019-08-30T19:33:48.651Z',
}),
expect.objectContaining({
from: {
__typename: 'Post',
content: 'You have been mentioned in a post',
id: 'p3',
},
read: false,
createdAt: '2019-08-31T17:33:48.651Z',
}),
]),
notifications: expect.arrayContaining(expected),
},
errors: undefined,
})
@ -213,34 +211,33 @@ describe('given some notifications', () => {
describe('filter for read: false', () => {
it('returns only unread notifications of current user', async () => {
const expected = expect.objectContaining({
data: {
notifications: expect.arrayContaining([
{
from: {
__typename: 'Comment',
content: 'You have been mentioned in a comment',
},
read: false,
createdAt: '2019-08-30T19:33:48.651Z',
},
{
from: {
__typename: 'Post',
content: 'You have been mentioned in a post',
},
read: false,
createdAt: '2019-08-31T17:33:48.651Z',
},
]),
},
})
const response = await query({
query: notifications,
variables: { ...variables, read: false },
})
await expect(response).toMatchObject({
data: {
notifications: expect.arrayContaining([
expect.objectContaining({
from: {
__typename: 'Comment',
content: 'You have been mentioned in a comment',
id: 'c2',
},
read: false,
createdAt: '2019-08-30T19:33:48.651Z',
}),
expect.objectContaining({
from: {
__typename: 'Post',
content: 'You have been mentioned in a post',
id: 'p3',
},
read: false,
createdAt: '2019-08-31T17:33:48.651Z',
}),
]),
},
})
await expect(response).toMatchObject(expected)
await expect(response.data?.notifications).toHaveLength(2) // double-check
})
@ -397,13 +394,11 @@ describe('given some notifications', () => {
{
createdAt: '2019-08-30T19:33:48.651Z',
from: { __typename: 'Comment', content: 'You have been mentioned in a comment' },
id: 'mentioned_in_comment/c2/you',
read: true,
},
{
createdAt: '2019-08-31T17:33:48.651Z',
from: { __typename: 'Post', content: 'You have been mentioned in a post' },
id: 'mentioned_in_post/p3/you',
read: true,
},
]),

View File

@ -1,368 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import Factory, { cleanDatabase } from '@db/factories'
import { ChangeGroupMemberRole } from '@graphql/queries/ChangeGroupMemberRole'
import { CreateGroup } from '@graphql/queries/CreateGroup'
import { CreatePost } from '@graphql/queries/CreatePost'
import { pinGroupPost } from '@graphql/queries/pinGroupPost'
import { profilePagePosts } from '@graphql/queries/profilePagePosts'
import { unpinGroupPost } from '@graphql/queries/unpinGroupPost'
import type { ApolloTestSetup } from '@root/test/helpers'
import { createApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
const defaultConfig = {
CATEGORIES_ACTIVE: false,
}
let config: Partial<Context['config']>
let anyUser
let allGroupsUser
let publicUser
let publicAdminUser
let authenticatedUser: Context['user']
const context = () => ({ authenticatedUser, config })
let mutate: ApolloTestSetup['mutate']
let query: ApolloTestSetup['query']
let database: ApolloTestSetup['database']
let server: ApolloTestSetup['server']
beforeAll(async () => {
await cleanDatabase()
const apolloSetup = createApolloTestSetup({ context })
mutate = apolloSetup.mutate
query = apolloSetup.query
database = apolloSetup.database
server = apolloSetup.server
})
afterAll(() => {
void server.stop()
void database.driver.close()
database.neode.close()
})
beforeEach(async () => {
config = { ...defaultConfig }
authenticatedUser = null
anyUser = await Factory.build('user', {
id: 'any-user',
name: 'Any User',
about: 'I am just an ordinary user and do not belong to any group.',
})
allGroupsUser = await Factory.build('user', {
id: 'all-groups-user',
name: 'All Groups User',
about: 'I am a member of all groups.',
})
publicUser = await Factory.build('user', {
id: 'public-user',
name: 'Public User',
about: 'I am the owner of the public group.',
})
publicAdminUser = await Factory.build('user', {
id: 'public-admin-user',
name: 'Public Admin User',
about: 'I am the admin of the public group.',
})
authenticatedUser = await publicUser.toJson()
await mutate({
mutation: CreateGroup,
variables: {
id: 'public-group',
name: 'The Public Group',
about: 'The public group!',
description: 'Anyone can see the posts of this group.',
groupType: 'public',
actionRadius: 'regional',
},
})
await mutate({
mutation: ChangeGroupMemberRole,
variables: {
groupId: 'public-group',
userId: 'all-groups-user',
roleInGroup: 'usual',
},
})
await mutate({
mutation: ChangeGroupMemberRole,
variables: {
groupId: 'public-group',
userId: 'public-admin-user',
roleInGroup: 'admin',
},
})
await mutate({
mutation: ChangeGroupMemberRole,
variables: {
groupId: 'closed-group',
userId: 'all-groups-user',
roleInGroup: 'usual',
},
})
authenticatedUser = await anyUser.toJson()
await mutate({
mutation: CreatePost,
variables: {
id: 'post-without-group',
title: 'A post without a group',
content: 'I am a user who does not belong to a group yet.',
},
})
authenticatedUser = await publicUser.toJson()
await mutate({
mutation: CreatePost,
variables: {
id: 'post-1-to-public-group',
title: 'Post 1 to a public group',
content: 'I am posting into a public group as a member of the group',
groupId: 'public-group',
},
})
await mutate({
mutation: CreatePost,
variables: {
id: 'post-2-to-public-group',
title: 'Post 1 to a public group',
content: 'I am posting into a public group as a member of the group',
groupId: 'public-group',
},
})
await mutate({
mutation: CreatePost,
variables: {
id: 'post-3-to-public-group',
title: 'Post 1 to a public group',
content: 'I am posting into a public group as a member of the group',
groupId: 'public-group',
},
})
})
afterEach(async () => {
await cleanDatabase()
})
describe('pin groupPosts', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
data: { pinGroupPost: null },
})
})
})
describe('ordinary users', () => {
it('throws authorization error', async () => {
authenticatedUser = await anyUser.toJson()
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
data: { pinGroupPost: null },
})
})
})
describe('group usual', () => {
it('throws authorization error', async () => {
authenticatedUser = await allGroupsUser.toJson()
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
data: { pinGroupPost: null },
})
})
})
describe('group admin', () => {
it('resolves without error', async () => {
authenticatedUser = await publicAdminUser.toJson()
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: undefined,
data: { pinGroupPost: { id: 'post-1-to-public-group', groupPinned: true } },
})
})
})
describe('group owner', () => {
it('resolves without error', async () => {
authenticatedUser = await publicUser.toJson()
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: undefined,
data: { pinGroupPost: { id: 'post-1-to-public-group', groupPinned: true } },
})
})
})
describe('MAX_GROUP_PINNED_POSTS is 1', () => {
beforeEach(async () => {
config = { ...defaultConfig, MAX_GROUP_PINNED_POSTS: 1 }
authenticatedUser = await publicUser.toJson()
})
it('returns post-1-to-public-group as first, pinned post', async () => {
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await expect(
query({
query: profilePagePosts,
variables: {
filter: { group: { id: 'public-group' } },
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
profilePagePosts: [
expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: null }),
expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: null }),
],
},
})
})
it('no error thrown when pinned post was pinned again', async () => {
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } }),
).resolves.toMatchObject({
errors: undefined,
data: { pinGroupPost: { id: 'post-1-to-public-group', groupPinned: true } },
})
})
it('returns post-2-to-public-group as first, pinned post', async () => {
authenticatedUser = await publicUser.toJson()
await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } })
await expect(
query({
query: profilePagePosts,
variables: {
filter: { group: { id: 'public-group' } },
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
profilePagePosts: [
expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: null }),
expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: null }),
],
},
})
})
it('returns post-3-to-public-group as first, pinned post, when multiple are pinned', async () => {
authenticatedUser = await publicUser.toJson()
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } })
await mutate({ mutation: pinGroupPost, variables: { id: 'post-3-to-public-group' } })
await expect(
query({
query: profilePagePosts,
variables: {
filter: { group: { id: 'public-group' } },
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
profilePagePosts: [
expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: null }),
expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: null }),
],
},
})
})
})
describe('MAX_GROUP_PINNED_POSTS is 2', () => {
beforeEach(async () => {
config = { ...defaultConfig, MAX_GROUP_PINNED_POSTS: 2 }
authenticatedUser = await publicUser.toJson()
})
it('returns post-1-to-public-group as first, post-2-to-public-group as second pinned post', async () => {
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } })
await expect(
query({
query: profilePagePosts,
variables: {
filter: { group: { id: 'public-group' } },
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
profilePagePosts: [
expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: null }),
],
},
})
})
it('throws an error when three posts are pinned', async () => {
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } })
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-3-to-public-group' } }),
).resolves.toMatchObject({
errors: [{ message: 'Reached maxed pinned posts already. Unpin a post first.' }],
data: {
pinGroupPost: null,
},
})
})
it('throws no error when first unpinned before a third post is pinned', async () => {
await mutate({ mutation: pinGroupPost, variables: { id: 'post-1-to-public-group' } })
await mutate({ mutation: pinGroupPost, variables: { id: 'post-2-to-public-group' } })
await mutate({ mutation: unpinGroupPost, variables: { id: 'post-1-to-public-group' } })
await expect(
mutate({ mutation: pinGroupPost, variables: { id: 'post-3-to-public-group' } }),
).resolves.toMatchObject({
errors: undefined,
})
await expect(
query({
query: profilePagePosts,
variables: {
filter: { group: { id: 'public-group' } },
orderBy: ['groupPinned_asc', 'sortDate_desc'],
},
}),
).resolves.toMatchObject({
errors: undefined,
data: {
profilePagePosts: [
expect.objectContaining({ id: 'post-3-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-2-to-public-group', groupPinned: true }),
expect.objectContaining({ id: 'post-1-to-public-group', groupPinned: null }),
],
},
})
})
})
})

View File

@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import gql from 'graphql-tag'
import Factory, { cleanDatabase } from '@db/factories'
import { CreateComment } from '@graphql/queries/CreateComment'
import { CreatePost } from '@graphql/queries/CreatePost'
import { Post } from '@graphql/queries/Post'
import { toggleObservePost } from '@graphql/queries/toggleObservePost'
import type { ApolloTestSetup } from '@root/test/helpers'
import { createApolloTestSetup } from '@root/test/helpers'
@ -20,6 +20,25 @@ let query: ApolloTestSetup['query']
let database: ApolloTestSetup['database']
let server: ApolloTestSetup['server']
const createCommentMutation = gql`
mutation ($id: ID, $postId: ID!, $content: String!) {
CreateComment(id: $id, postId: $postId, content: $content) {
id
isPostObservedByMe
postObservingUsersCount
}
}
`
const postQuery = gql`
query Post($id: ID) {
Post(id: $id) {
isObservedByMe
observingUsersCount
}
}
`
beforeAll(async () => {
await cleanDatabase()
const apolloSetup = createApolloTestSetup({ context })
@ -82,7 +101,7 @@ describe('observing posts', () => {
it('has another user NOT observing the post BEFORE commenting it', async () => {
await expect(
query({
query: Post,
query: postQuery,
variables: { id: 'p2' },
}),
).resolves.toMatchObject({
@ -101,7 +120,7 @@ describe('observing posts', () => {
it('has another user observing the post AFTER commenting it', async () => {
await expect(
mutate({
mutation: CreateComment,
mutation: createCommentMutation,
variables: {
postId: 'p2',
content: 'After commenting the post, I should observe the post automatically',
@ -118,7 +137,7 @@ describe('observing posts', () => {
await expect(
query({
query: Post,
query: postQuery,
variables: { id: 'p2' },
}),
).resolves.toMatchObject({
@ -166,7 +185,7 @@ describe('observing posts', () => {
it('does NOT alter the observation state', async () => {
await expect(
mutate({
mutation: CreateComment,
mutation: createCommentMutation,
variables: {
postId: 'p2',
content:
@ -184,7 +203,7 @@ describe('observing posts', () => {
await expect(
query({
query: Post,
query: postQuery,
variables: { id: 'p2' },
}),
).resolves.toMatchObject({

View File

@ -2,6 +2,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import gql from 'graphql-tag'
import Factory, { cleanDatabase } from '@db/factories'
import Image from '@db/models/Image'
import { AddPostEmotions } from '@graphql/queries/AddPostEmotions'
@ -16,7 +18,6 @@ import { pushPost } from '@graphql/queries/pushPost'
import { RemovePostEmotions } from '@graphql/queries/RemovePostEmotions'
import { unpinPost } from '@graphql/queries/unpinPost'
import { unpushPost } from '@graphql/queries/unpushPost'
import { UpdatePost } from '@graphql/queries/UpdatePost'
import type { ApolloTestSetup } from '@root/test/helpers'
import { createApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
@ -132,14 +133,18 @@ describe('Post', () => {
describe('no filter', () => {
it('returns all posts', async () => {
const postQueryNoFilters = gql`
query Post($filter: _PostFilter) {
Post(filter: $filter) {
id
}
}
`
const expected = [{ id: 'happy-post' }, { id: 'cry-post' }, { id: 'post-by-followed-user' }]
variables = { filter: {} }
await expect(query({ query: Post, variables })).resolves.toMatchObject({
await expect(query({ query: postQueryNoFilters, variables })).resolves.toMatchObject({
data: {
Post: expect.arrayContaining([
expect.objectContaining({ id: 'happy-post' }),
expect.objectContaining({ id: 'cry-post' }),
expect.objectContaining({ id: 'post-by-followed-user' }),
]),
Post: expect.arrayContaining(expected),
},
})
})
@ -173,6 +178,17 @@ describe('Post', () => {
}) */
describe('by emotions', () => {
const postQueryFilteredByEmotions = gql`
query Post($filter: _PostFilter) {
Post(filter: $filter) {
id
emotions {
emotion
}
}
}
`
it('filters by single emotion', async () => {
const expected = {
data: {
@ -186,25 +202,30 @@ describe('Post', () => {
}
await user.relateTo(happyPost, 'emoted', { emotion: 'happy' })
variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy'] } } }
await expect(query({ query: Post, variables })).resolves.toMatchObject(expected)
await expect(
query({ query: postQueryFilteredByEmotions, variables }),
).resolves.toMatchObject(expected)
})
it('filters by multiple emotions', async () => {
const expected = [
{
id: 'happy-post',
emotions: [{ emotion: 'happy' }],
},
{
id: 'cry-post',
emotions: [{ emotion: 'cry' }],
},
]
await user.relateTo(happyPost, 'emoted', { emotion: 'happy' })
await user.relateTo(cryPost, 'emoted', { emotion: 'cry' })
variables = { ...variables, filter: { emotions_some: { emotion_in: ['happy', 'cry'] } } }
await expect(query({ query: Post, variables })).resolves.toMatchObject({
await expect(
query({ query: postQueryFilteredByEmotions, variables }),
).resolves.toMatchObject({
data: {
Post: expect.arrayContaining([
expect.objectContaining({
id: 'happy-post',
emotions: [expect.objectContaining({ emotion: 'happy' })],
}),
expect.objectContaining({
id: 'cry-post',
emotions: [expect.objectContaining({ emotion: 'cry' })],
}),
]),
Post: expect.arrayContaining(expected),
},
errors: undefined,
})
@ -212,9 +233,22 @@ describe('Post', () => {
})
it('by followed-by', async () => {
const postQueryFilteredByUsersFollowed = gql`
query Post($filter: _PostFilter) {
Post(filter: $filter) {
id
author {
name
}
}
}
`
await user.relateTo(followedUser, 'following')
variables = { filter: { author: { followedBy_some: { id: 'current-user' } } } }
await expect(query({ query: Post, variables })).resolves.toMatchObject({
await expect(
query({ query: postQueryFilteredByUsersFollowed, variables }),
).resolves.toMatchObject({
data: {
Post: [
{
@ -621,6 +655,48 @@ describe('CreatePost', () => {
describe('UpdatePost', () => {
let author, newlyCreatedPost
const updatePostMutation = gql`
mutation (
$id: ID!
$title: String!
$content: String!
$image: ImageInput
$categoryIds: [ID]
$postType: PostType
$eventInput: _EventInput
) {
UpdatePost(
id: $id
title: $title
content: $content
image: $image
categoryIds: $categoryIds
postType: $postType
eventInput: $eventInput
) {
id
title
content
author {
name
slug
}
createdAt
updatedAt
categories {
id
}
postType
eventStart
eventLocationName
eventVenue
eventLocation {
lng
lat
}
}
}
`
beforeEach(async () => {
author = await Factory.build('user', { slug: 'the-author' })
authenticatedUser = await author.toJson()
@ -643,7 +719,7 @@ describe('UpdatePost', () => {
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
await expect(mutate({ mutation: UpdatePost, variables })).resolves.toMatchObject({
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject({
errors: [{ message: 'Not Authorized!' }],
data: { UpdatePost: null },
})
@ -656,7 +732,7 @@ describe('UpdatePost', () => {
})
it('throws authorization error', async () => {
const { errors } = await mutate({ mutation: UpdatePost, variables })
const { errors } = await mutate({ mutation: updatePostMutation, variables })
expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!')
})
})
@ -671,7 +747,9 @@ describe('UpdatePost', () => {
data: { UpdatePost: { id: newlyCreatedPost.id, content: 'New content' } },
errors: undefined,
}
await expect(mutate({ mutation: UpdatePost, variables })).resolves.toMatchObject(expected)
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('updates a post, but maintains non-updated attributes', async () => {
@ -685,16 +763,18 @@ describe('UpdatePost', () => {
},
errors: undefined,
}
await expect(mutate({ mutation: UpdatePost, variables })).resolves.toMatchObject(expected)
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected,
)
})
it('updates the updatedAt attribute', async () => {
const {
data: { UpdatePost: UpdatePostData },
} = (await mutate({ mutation: UpdatePost, variables })) as any // eslint-disable-line @typescript-eslint/no-explicit-any
expect(UpdatePostData.updatedAt).toBeTruthy()
expect(Date.parse(UpdatePostData.updatedAt)).toEqual(expect.any(Number))
expect(newlyCreatedPost.updatedAt).not.toEqual(UpdatePostData.updatedAt)
data: { UpdatePost },
} = (await mutate({ mutation: updatePostMutation, variables })) as any // eslint-disable-line @typescript-eslint/no-explicit-any
expect(UpdatePost.updatedAt).toBeTruthy()
expect(Date.parse(UpdatePost.updatedAt)).toEqual(expect.any(Number))
expect(newlyCreatedPost.updatedAt).not.toEqual(UpdatePost.updatedAt)
})
describe('no new category ids provided for update', () => {
@ -708,7 +788,9 @@ describe('UpdatePost', () => {
},
errors: undefined,
}
await expect(mutate({ mutation: UpdatePost, variables })).resolves.toMatchObject(expected)
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
@ -727,7 +809,9 @@ describe('UpdatePost', () => {
},
errors: undefined,
}
await expect(mutate({ mutation: UpdatePost, variables })).resolves.toMatchObject(expected)
await expect(mutate({ mutation: updatePostMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
@ -736,7 +820,7 @@ describe('UpdatePost', () => {
it('throws an error', async () => {
await expect(
mutate({
mutation: UpdatePost,
mutation: updatePostMutation,
variables: { ...variables, postType: 'Event' },
}),
).resolves.toMatchObject({
@ -753,7 +837,7 @@ describe('UpdatePost', () => {
it('throws an error', async () => {
await expect(
mutate({
mutation: UpdatePost,
mutation: updatePostMutation,
variables: {
...variables,
postType: 'Event',
@ -777,7 +861,7 @@ describe('UpdatePost', () => {
const now = new Date()
await expect(
mutate({
mutation: UpdatePost,
mutation: updatePostMutation,
variables: {
...variables,
postType: 'Event',
@ -801,7 +885,7 @@ describe('UpdatePost', () => {
const now = new Date()
await expect(
mutate({
mutation: UpdatePost,
mutation: updatePostMutation,
variables: {
...variables,
postType: 'Event',
@ -826,7 +910,7 @@ describe('UpdatePost', () => {
const now = new Date()
await expect(
mutate({
mutation: UpdatePost,
mutation: updatePostMutation,
variables: {
...variables,
postType: 'Event',
@ -852,7 +936,7 @@ describe('UpdatePost', () => {
const now = new Date()
await expect(
mutate({
mutation: UpdatePost,
mutation: updatePostMutation,
variables: {
...variables,
postType: 'Event',
@ -892,7 +976,7 @@ describe('UpdatePost', () => {
await expect(
database.neode.first<typeof Image>('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
await mutate({ mutation: UpdatePost, variables })
await mutate({ mutation: updatePostMutation, variables })
await expect(
database.neode.first<typeof Image>('Image', { sensitive: true }, undefined),
).resolves.toBeTruthy()
@ -905,7 +989,7 @@ describe('UpdatePost', () => {
})
it('deletes the image', async () => {
await expect(database.neode.all('Image')).resolves.toHaveLength(6)
await mutate({ mutation: UpdatePost, variables })
await mutate({ mutation: updatePostMutation, variables })
await expect(database.neode.all('Image')).resolves.toHaveLength(5)
})
})
@ -918,7 +1002,7 @@ describe('UpdatePost', () => {
await expect(
database.neode.first<typeof Image>('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
await mutate({ mutation: UpdatePost, variables })
await mutate({ mutation: updatePostMutation, variables })
await expect(
database.neode.first<typeof Image>('Image', { sensitive: true }, undefined),
).resolves.toBeFalsy()
@ -2047,6 +2131,25 @@ describe('DeletePost', () => {
describe('emotions', () => {
let author, postToEmote
const PostsEmotionsCountQuery = gql`
query ($id: ID!) {
Post(id: $id) {
emotionsCount
}
}
`
const PostsEmotionsQuery = gql`
query ($id: ID!) {
Post(id: $id) {
emotions {
emotion
User {
id
}
}
}
}
`
beforeEach(async () => {
author = await database.neode.create('User', { id: 'u257' })
@ -2123,8 +2226,8 @@ describe('emotions', () => {
await mutate({ mutation: AddPostEmotions, variables })
await mutate({ mutation: AddPostEmotions, variables })
await expect(
query({ query: Post, variables: postsEmotionsQueryVariables }),
).resolves.toMatchObject(expected)
query({ query: PostsEmotionsCountQuery, variables: postsEmotionsQueryVariables }),
).resolves.toEqual(expect.objectContaining(expected))
})
it('allows a user to add more than one emotion', async () => {
@ -2144,8 +2247,8 @@ describe('emotions', () => {
variables = { ...variables, data: { emotion: 'surprised' } }
await mutate({ mutation: AddPostEmotions, variables })
await expect(
query({ query: Post, variables: postsEmotionsQueryVariables }),
).resolves.toMatchObject(expected)
query({ query: PostsEmotionsQuery, variables: postsEmotionsQueryVariables }),
).resolves.toEqual(expect.objectContaining(expected))
})
})
@ -2248,8 +2351,8 @@ describe('emotions', () => {
variables: removePostEmotionsVariables,
})
await expect(
query({ query: Post, variables: postsEmotionsQueryVariables }),
).resolves.toMatchObject(expectedResponse)
query({ query: PostsEmotionsQuery, variables: postsEmotionsQueryVariables }),
).resolves.toEqual(expect.objectContaining(expectedResponse))
})
})
})

View File

@ -29,20 +29,6 @@ const maintainPinnedPosts = (params) => {
return params
}
const maintainGroupPinnedPosts = (params) => {
// only show GroupPinnedPosts when Groups is selected
if (!params.filter?.group) {
return params
}
const pinnedPostFilter = { groupPinned: true, group: params.filter.group }
if (isEmpty(params.filter)) {
params.filter = { OR: [pinnedPostFilter, {}] }
} else {
params.filter = { OR: [pinnedPostFilter, { ...params.filter }] }
}
return params
}
const filterEventDates = (params) => {
if (params.filter?.eventStart_gte) {
const date = params.filter.eventStart_gte
@ -66,7 +52,6 @@ export default {
params = await filterPostsOfMyGroups(params, context)
params = await filterInvisiblePosts(params, context)
params = await filterForMutedUsers(params, context)
params = await maintainGroupPinnedPosts(params)
return neo4jgraphql(object, params, context, resolveInfo)
},
PostsEmotionsCountByEmotion: async (_object, params, context, _resolveInfo) => {
@ -169,7 +154,7 @@ export default {
)`
}
const categoriesCypher =
config.CATEGORIES_ACTIVE && categoryIds && categoryIds.length > 0
config.CATEGORIES_ACTIVE && categoryIds
? `WITH post
UNWIND $categoryIds AS categoryId
MATCH (category:Category {id: categoryId})
@ -468,68 +453,6 @@ export default {
}
return unpinnedPost
},
pinGroupPost: async (_parent, params, context: Context, _resolveInfo) => {
if (!context.user) {
throw new Error('Missing authenticated user.')
}
const { config } = context
if (config.MAX_GROUP_PINNED_POSTS === 0) {
throw new Error('Pinned posts are not allowed!')
}
// If MAX_GROUP_PINNED_POSTS === 1 -> Delete old pin
if (config.MAX_GROUP_PINNED_POSTS === 1) {
await context.database.write({
query: `
MATCH (post:Post {id: $params.id})-[:IN]->(group:Group)
MATCH (:User)-[pinned:GROUP_PINNED]->(oldPinnedPost:Post)-[:IN]->(:Group {id: group.id})
REMOVE oldPinnedPost.groupPinned
DELETE pinned`,
variables: { user: context.user, params },
})
// If MAX_GROUP_PINNED_POSTS !== 1 -> Check if max is reached
} else {
const result = await context.database.query({
query: `
MATCH (post:Post {id: $params.id})-[:IN]->(group:Group)
MATCH (:User)-[pinned:GROUP_PINNED]->(pinnedPosts:Post)-[:IN]->(:Group {id: group.id})
RETURN toString(count(pinnedPosts)) as count`,
variables: { user: context.user, params },
})
if (result.records[0].get('count') >= config.MAX_GROUP_PINNED_POSTS) {
throw new Error('Reached maxed pinned posts already. Unpin a post first.')
}
}
// Set new pin
const result = await context.database.write({
query: `
MATCH (user:User {id: $user.id})
MATCH (post:Post {id: $params.id})-[:IN]->(group:Group)
MERGE (user)-[pinned:GROUP_PINNED {createdAt: toString(datetime())}]->(post)
SET post.groupPinned = true
RETURN post {.*, pinnedAt: pinned.createdAt}`,
variables: { user: context.user, params },
})
// Return post
return result.records[0].get('post')
},
unpinGroupPost: async (_parent, params, context, _resolveInfo) => {
const result = await context.database.write({
query: `
MATCH (post:Post {id: $postId})
OPTIONAL MATCH (:User)-[pinned:GROUP_PINNED]->(post)
DELETE pinned
REMOVE post.groupPinned
RETURN post {.*}`,
variables: { postId: params.id },
})
// Return post
return result.records[0].get('post')
},
markTeaserAsViewed: async (_parent, params, context, _resolveInfo) => {
const session = context.driver.session()
const writeTxResultPromise = session.writeTransaction(async (transaction) => {
@ -627,7 +550,6 @@ export default {
'language',
'pinnedAt',
'pinned',
'groupPinned',
'eventVenue',
'eventLocation',
'eventLocationName',
@ -667,21 +589,6 @@ export default {
'MATCH (this)<-[obs:OBSERVES]-(related:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(related) >= 1',
},
}),
// As long as we rely on the filter capabilities of the neo4jgraphql library,
// we cannot filter on a relation or their properties.
// Hence we need to save the value to the group node in the database.
/* groupPinned: async (parent, _params, context, _resolveInfo) => {
return (
(
await context.database.query({
query: `
MATCH (:User)-[pinned:GROUP_PINNED]->(:Post {id: $parent.id})
RETURN pinned`,
variables: { parent },
})
).records.length === 1
)
}, */
relatedContributions: async (parent, _params, context, _resolveInfo) => {
if (typeof parent.relatedContributions !== 'undefined') return parent.relatedContributions
const { id } = parent

View File

@ -1,11 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import gql from 'graphql-tag'
import Factory, { cleanDatabase } from '@db/factories'
import EmailAddress from '@db/models/EmailAddress'
import User from '@db/models/User'
import { Signup } from '@graphql/queries/Signup'
import { SignupVerification } from '@graphql/queries/SignupVerification'
import type { ApolloTestSetup } from '@root/test/helpers'
import { createApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
@ -157,6 +158,31 @@ describe('Signup', () => {
})
describe('SignupVerification', () => {
const mutation = gql`
mutation (
$name: String!
$password: String!
$email: String!
$nonce: String!
$about: String
$termsAndConditionsAgreedVersion: String!
$locale: String
) {
SignupVerification(
name: $name
password: $password
email: $email
nonce: $nonce
about: $about
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locale: $locale
) {
id
termsAndConditionsAgreedVersion
termsAndConditionsAgreedAt
}
}
`
describe('given valid password and email', () => {
beforeEach(() => {
variables = {
@ -193,9 +219,7 @@ describe('SignupVerification', () => {
})
it('rejects', async () => {
await expect(
mutate({ mutation: SignupVerification, variables }),
).resolves.toMatchObject({
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
errors: [{ message: 'Invalid email or nonce' }],
})
})
@ -213,9 +237,7 @@ describe('SignupVerification', () => {
describe('sending a valid nonce', () => {
it('creates a user account', async () => {
await expect(
mutate({ mutation: SignupVerification, variables }),
).resolves.toMatchObject({
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: {
SignupVerification: expect.objectContaining({
id: expect.any(String),
@ -225,7 +247,7 @@ describe('SignupVerification', () => {
})
it('sets `verifiedAt` attribute of EmailAddress', async () => {
await mutate({ mutation: SignupVerification, variables })
await mutate({ mutation, variables })
const email = await database.neode.first(
'EmailAddress',
{ email: 'john@example.org' },
@ -243,14 +265,14 @@ describe('SignupVerification', () => {
MATCH(email:EmailAddress)-[:BELONGS_TO]->(u:User {name: $name})
RETURN email
`
await mutate({ mutation: SignupVerification, variables })
await mutate({ mutation, variables })
const { records: emails } = await database.neode.cypher(cypher, { name: 'John Doe' })
expect(emails).toHaveLength(1)
})
it('sets `about` attribute of User', async () => {
variables = { ...variables, about: 'Find this description in the user profile' }
await mutate({ mutation: SignupVerification, variables })
await mutate({ mutation, variables })
const user = await database.neode.first<typeof User>(
'User',
{ name: 'John Doe' },
@ -263,9 +285,7 @@ describe('SignupVerification', () => {
it('allowing the about field to be an empty string', async () => {
variables = { ...variables, about: '' }
await expect(
mutate({ mutation: SignupVerification, variables }),
).resolves.toMatchObject({
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: {
SignupVerification: expect.objectContaining({
id: expect.any(String),
@ -279,15 +299,13 @@ describe('SignupVerification', () => {
MATCH(email:EmailAddress)<-[:PRIMARY_EMAIL]-(u:User {name: $name})
RETURN email
`
await mutate({ mutation: SignupVerification, variables })
await mutate({ mutation, variables })
const { records: emails } = await database.neode.cypher(cypher, { name: 'John Doe' })
expect(emails).toHaveLength(1)
})
it('updates termsAndConditionsAgreedVersion', async () => {
await expect(
mutate({ mutation: SignupVerification, variables }),
).resolves.toMatchObject({
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: {
SignupVerification: expect.objectContaining({
termsAndConditionsAgreedVersion: '0.1.0',
@ -297,9 +315,7 @@ describe('SignupVerification', () => {
})
it('updates termsAndConditionsAgreedAt', async () => {
await expect(
mutate({ mutation: SignupVerification, variables }),
).resolves.toMatchObject({
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
data: {
SignupVerification: expect.objectContaining({
termsAndConditionsAgreedAt: expect.any(String),
@ -310,9 +326,7 @@ describe('SignupVerification', () => {
it('rejects if version of terms and conditions is missing', async () => {
variables = { ...variables, termsAndConditionsAgreedVersion: null }
await expect(
mutate({ mutation: SignupVerification, variables }),
).resolves.toMatchObject({
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
errors: [
{
message:
@ -324,9 +338,7 @@ describe('SignupVerification', () => {
it('rejects if version of terms and conditions has wrong format', async () => {
variables = { ...variables, termsAndConditionsAgreedVersion: 'invalid version format' }
await expect(
mutate({ mutation: SignupVerification, variables }),
).resolves.toMatchObject({
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
errors: [{ message: 'Invalid version format!' }],
})
})
@ -338,9 +350,7 @@ describe('SignupVerification', () => {
})
it('rejects', async () => {
await expect(
mutate({ mutation: SignupVerification, variables }),
).resolves.toMatchObject({
await expect(mutate({ mutation, variables })).resolves.toMatchObject({
errors: [{ message: 'Invalid email or nonce' }],
})
})

View File

@ -14,7 +14,7 @@ import createServer from '@src/server'
const instance = getNeode()
const driver = getDriver()
describe('reports', () => {
describe('file a report on a resource', () => {
let authenticatedUser, currentUser, mutate, query, moderator, abusiveUser, otherReportingUser
const categoryIds = ['cat9']
const variables = {
@ -620,31 +620,32 @@ describe('reports', () => {
),
])
authenticatedUser = await currentUser.toJson()
// Sequential to ensure distinct createdAt values for orderBy tests
await mutate({
mutation: fileReport,
variables: {
resourceId: 'abusive-post-1',
reasonCategory: 'other',
reasonDescription: 'This post is bigoted',
},
})
await mutate({
mutation: fileReport,
variables: {
resourceId: 'abusive-comment-1',
reasonCategory: 'discrimination_etc',
reasonDescription: 'This comment is bigoted',
},
})
await mutate({
mutation: fileReport,
variables: {
resourceId: 'abusive-user-1',
reasonCategory: 'doxing',
reasonDescription: 'This user is harassing me with bigoted remarks',
},
})
await Promise.all([
mutate({
mutation: fileReport,
variables: {
resourceId: 'abusive-post-1',
reasonCategory: 'other',
reasonDescription: 'This comment is bigoted',
},
}),
mutate({
mutation: fileReport,
variables: {
resourceId: 'abusive-comment-1',
reasonCategory: 'discrimination_etc',
reasonDescription: 'This post is bigoted',
},
}),
mutate({
mutation: fileReport,
variables: {
resourceId: 'abusive-user-1',
reasonCategory: 'doxing',
reasonDescription: 'This user is harassing me with bigoted remarks',
},
}),
])
authenticatedUser = null
})
@ -659,250 +660,82 @@ describe('reports', () => {
})
describe('authenticated', () => {
describe('as user', () => {
beforeEach(async () => {
authenticatedUser = await currentUser.toJson()
})
it('returns no reports', async () => {
await expect(query({ query: reports })).resolves.toMatchObject({
data: { reports: null },
errors: [{ message: 'Not Authorized!' }],
})
it('role "user" gets no reports', async () => {
authenticatedUser = await currentUser.toJson()
await expect(query({ query: reports })).resolves.toMatchObject({
data: { reports: null },
errors: [{ message: 'Not Authorized!' }],
})
})
describe('as moderator', () => {
beforeEach(async () => {
authenticatedUser = await moderator.toJson()
})
it('gets reports', async () => {
const expected = {
reports: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
closed: false,
resource: {
__typename: 'User',
id: 'abusive-user-1',
},
filed: expect.arrayContaining([
expect.objectContaining({
submitter: expect.objectContaining({
id: 'current-user-id',
}),
createdAt: expect.any(String),
reasonCategory: 'doxing',
reasonDescription: 'This user is harassing me with bigoted remarks',
it('role "moderator" gets reports', async () => {
const expected = {
reports: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
closed: false,
resource: {
__typename: 'User',
id: 'abusive-user-1',
},
filed: expect.arrayContaining([
expect.objectContaining({
submitter: expect.objectContaining({
id: 'current-user-id',
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
closed: false,
resource: {
__typename: 'Post',
id: 'abusive-post-1',
},
filed: expect.arrayContaining([
expect.objectContaining({
submitter: expect.objectContaining({
id: 'current-user-id',
}),
createdAt: expect.any(String),
reasonCategory: 'other',
reasonDescription: 'This post is bigoted',
createdAt: expect.any(String),
reasonCategory: 'doxing',
reasonDescription: 'This user is harassing me with bigoted remarks',
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
closed: false,
resource: {
__typename: 'Post',
id: 'abusive-post-1',
},
filed: expect.arrayContaining([
expect.objectContaining({
submitter: expect.objectContaining({
id: 'current-user-id',
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
closed: false,
resource: {
__typename: 'Comment',
id: 'abusive-comment-1',
},
filed: expect.arrayContaining([
expect.objectContaining({
submitter: expect.objectContaining({
id: 'current-user-id',
}),
createdAt: expect.any(String),
reasonCategory: 'discrimination_etc',
reasonDescription: 'This comment is bigoted',
createdAt: expect.any(String),
reasonCategory: 'other',
reasonDescription: 'This comment is bigoted',
}),
]),
}),
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
closed: false,
resource: {
__typename: 'Comment',
id: 'abusive-comment-1',
},
filed: expect.arrayContaining([
expect.objectContaining({
submitter: expect.objectContaining({
id: 'current-user-id',
}),
]),
}),
]),
}
const { data } = await query({ query: reports })
expect(data).toEqual(expected)
})
describe('orderBy', () => {
it('createdAt_asc returns reports in ascending order', async () => {
const { data } = await query({
query: reports,
variables: { orderBy: 'createdAt_asc' },
})
const sorted = [...data.reports].sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1))
expect(data.reports).toEqual(sorted)
})
it('createdAt_desc returns reports in descending order', async () => {
const { data } = await query({
query: reports,
variables: { orderBy: 'createdAt_desc' },
})
const sorted = [...data.reports].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
expect(data.reports).toEqual(sorted)
})
})
describe('reviewed filter', () => {
it('reviewed: false returns only unreviewed reports', async () => {
const { data } = await query({
query: reports,
variables: { reviewed: false },
})
expect(data.reports).toHaveLength(3)
})
it('reviewed: true returns only reviewed reports', async () => {
// review one report
await mutate({
mutation: review,
variables: { resourceId: 'abusive-post-1', disable: false, closed: false },
})
const { data } = await query({
query: reports,
variables: { reviewed: true },
})
expect(data.reports).toHaveLength(1)
expect(data.reports[0].resource.id).toBe('abusive-post-1')
})
})
describe('closed filter', () => {
it('closed: false returns only open reports', async () => {
const { data } = await query({
query: reports,
variables: { closed: false },
})
expect(data.reports).toHaveLength(3)
data.reports.forEach((report) => {
expect(report.closed).toBe(false)
})
})
it('closed: true returns only closed reports', async () => {
// close one report via review
await mutate({
mutation: review,
variables: { resourceId: 'abusive-post-1', disable: false, closed: true },
})
const { data } = await query({
query: reports,
variables: { closed: true },
})
expect(data.reports).toHaveLength(1)
expect(data.reports[0].resource.id).toBe('abusive-post-1')
expect(data.reports[0].closed).toBe(true)
})
})
describe('combined reviewed and closed filter', () => {
it('returns only reports matching both filters', async () => {
// review and close one report
await mutate({
mutation: review,
variables: { resourceId: 'abusive-post-1', disable: false, closed: true },
})
// review but keep open another report
await mutate({
mutation: review,
variables: { resourceId: 'abusive-user-1', disable: false, closed: false },
})
const { data } = await query({
query: reports,
variables: { reviewed: true, closed: true },
})
expect(data.reports).toHaveLength(1)
expect(data.reports[0].resource.id).toBe('abusive-post-1')
expect(data.reports[0].closed).toBe(true)
})
it('reviewed: true, closed: false returns reviewed but open reports', async () => {
// review and close one report
await mutate({
mutation: review,
variables: { resourceId: 'abusive-post-1', disable: false, closed: true },
})
// review but keep open another report
await mutate({
mutation: review,
variables: { resourceId: 'abusive-user-1', disable: false, closed: false },
})
const { data } = await query({
query: reports,
variables: { reviewed: true, closed: false },
})
expect(data.reports).toHaveLength(1)
expect(data.reports[0].resource.id).toBe('abusive-user-1')
expect(data.reports[0].closed).toBe(false)
})
})
describe('pagination', () => {
it('first: 2 returns only 2 reports', async () => {
const { data } = await query({
query: reports,
variables: { first: 2 },
})
expect(data.reports).toHaveLength(2)
})
it('first: 1 returns only 1 report', async () => {
const { data } = await query({
query: reports,
variables: { first: 1 },
})
expect(data.reports).toHaveLength(1)
})
it('offset: 1 skips the first report', async () => {
const { data: allData } = await query({
query: reports,
variables: { orderBy: 'createdAt_asc' },
})
const { data: offsetData } = await query({
query: reports,
variables: { orderBy: 'createdAt_asc', offset: 1 },
})
expect(offsetData.reports).toHaveLength(allData.reports.length - 1)
expect(offsetData.reports[0].id).toBe(allData.reports[1].id)
})
it('first and offset combined for paging', async () => {
const { data: allData } = await query({
query: reports,
variables: { orderBy: 'createdAt_asc' },
})
const { data: pageData } = await query({
query: reports,
variables: { orderBy: 'createdAt_asc', first: 1, offset: 1 },
})
expect(pageData.reports).toHaveLength(1)
expect(pageData.reports[0].id).toBe(allData.reports[1].id)
})
})
createdAt: expect.any(String),
reasonCategory: 'discrimination_etc',
reasonDescription: 'This post is bigoted',
}),
]),
}),
]),
}
authenticatedUser = await moderator.toJson()
const { data } = await query({ query: reports })
expect(data).toEqual(expected)
})
})
})

View File

@ -45,8 +45,7 @@ export default {
reports: async (_parent, params, context, _resolveInfo) => {
const { driver } = context
const session = driver.session()
let orderByClause
const filterClauses: string[] = []
let orderByClause, filterClause
switch (params.orderBy) {
case 'createdAt_asc':
orderByClause = 'ORDER BY report.createdAt ASC'
@ -60,24 +59,26 @@ export default {
switch (params.reviewed) {
case true:
filterClauses.push('AND ((report)<-[:REVIEWED]-(:User))')
filterClause = 'AND ((report)<-[:REVIEWED]-(:User))'
break
case false:
filterClauses.push('AND NOT ((report)<-[:REVIEWED]-(:User))')
filterClause = 'AND NOT ((report)<-[:REVIEWED]-(:User))'
break
default:
filterClause = ''
}
switch (params.closed) {
case true:
filterClauses.push('AND report.closed = true')
filterClause = 'AND report.closed = true'
break
case false:
filterClauses.push('AND report.closed = false')
filterClause = 'AND report.closed = false'
break
default:
break
}
const filterClause = filterClauses.join(' ')
const offset =
params.offset && typeof params.offset === 'number' ? `SKIP ${params.offset}` : ''
const limit = params.first && typeof params.first === 'number' ? `LIMIT ${params.first}` : ''
@ -113,8 +114,7 @@ export default {
},
},
Report: {
// This field is inline queried in the cypher statement above
/* filed: async (parent, _params, context, _resolveInfo) => {
filed: async (parent, _params, context, _resolveInfo) => {
if (typeof parent.filed !== 'undefined') return parent.filed
const session = context.driver.session()
const { id } = parent
@ -146,9 +146,9 @@ export default {
session.close()
}
return filed
}, */
},
reviewed: async (parent, _params, context, _resolveInfo) => {
// if (typeof parent.reviewed !== 'undefined') return parent.reviewed
if (typeof parent.reviewed !== 'undefined') return parent.reviewed
const session = context.driver.session()
const { id } = parent
let reviewed

View File

@ -1,103 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { ApolloServerTestClient, createTestClient } from 'apollo-server-testing'
import Factory, { cleanDatabase } from '@db/factories'
import { getDriver, getNeode } from '@db/neo4j'
import { availableRoles } from '@graphql/queries/availableRoles'
import createServer from '@src/server'
const instance = getNeode()
const driver = getDriver()
describe('availableRoles', () => {
let authenticatedUser
let query: ApolloServerTestClient['query']
beforeAll(async () => {
await cleanDatabase()
const { server } = createServer({
context: () => {
return {
driver,
neode: instance,
user: authenticatedUser,
}
},
})
query = createTestClient(server).query
})
afterAll(async () => {
await cleanDatabase()
await driver.close()
})
afterEach(async () => {
await cleanDatabase()
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
authenticatedUser = null
const { data, errors } = await query({ query: availableRoles })
expect(data).toEqual(null)
expect(errors).toEqual([expect.objectContaining({ message: 'Not Authorized!' })])
})
})
describe('authenticated', () => {
describe('as user', () => {
beforeEach(async () => {
const user = await Factory.build(
'user',
{ id: 'user-id', role: 'user' },
{ email: 'user@example.org', password: '1234' },
)
authenticatedUser = await user.toJson()
})
it('throws authorization error', async () => {
const { data, errors } = await query({ query: availableRoles })
expect(data).toEqual(null)
expect(errors).toEqual([expect.objectContaining({ message: 'Not Authorized!' })])
})
})
describe('as moderator', () => {
beforeEach(async () => {
const moderator = await Factory.build(
'user',
{ id: 'moderator-id', role: 'moderator' },
{ email: 'moderator@example.org', password: '1234' },
)
authenticatedUser = await moderator.toJson()
})
it('throws authorization error', async () => {
const { data, errors } = await query({ query: availableRoles })
expect(data).toEqual(null)
expect(errors).toEqual([expect.objectContaining({ message: 'Not Authorized!' })])
})
})
describe('as admin', () => {
beforeEach(async () => {
const admin = await Factory.build(
'user',
{ id: 'admin-id', role: 'admin' },
{ email: 'admin@example.org', password: '1234' },
)
authenticatedUser = await admin.toJson()
})
it('returns available roles', async () => {
const { data, errors } = await query({ query: availableRoles })
expect(errors).toBeUndefined()
expect(data?.availableRoles).toEqual(['admin', 'moderator', 'user'])
})
})
})
})

View File

@ -2,10 +2,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import { searchPosts } from '@graphql/queries/searchPosts'
import { searchResults } from '@graphql/queries/searchResults'
import createServer from '@src/server'
@ -34,6 +34,19 @@ afterAll(async () => {
await driver.close()
neode.close()
})
const searchPostQuery = gql`
query ($query: String!, $firstPosts: Int, $postsOffset: Int) {
searchPosts(query: $query, firstPosts: $firstPosts, postsOffset: $postsOffset) {
postCount
posts {
__typename
id
title
content
}
}
}
`
describe('resolvers/searches', () => {
let variables
@ -592,7 +605,7 @@ und hinter tausend Stäben keine Welt.`,
describe('query with limit 1', () => {
it('has a count greater than 1', async () => {
variables = { query: 'beitrag', firstPosts: 1, postsOffset: 0 }
await expect(query({ query: searchPosts, variables })).resolves.toMatchObject({
await expect(query({ query: searchPostQuery, variables })).resolves.toMatchObject({
data: {
searchPosts: {
postCount: 2,

View File

@ -3,10 +3,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import Factory, { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import { Post } from '@graphql/queries/Post'
import { shout } from '@graphql/queries/shout'
import { unshout } from '@graphql/queries/unshout'
import createServer from '@src/server'
@ -14,6 +14,16 @@ import createServer from '@src/server'
let mutate, query, authenticatedUser, variables
const instance = getNeode()
const driver = getDriver()
const queryPost = gql`
query ($id: ID!) {
Post(id: $id) {
id
shoutedBy {
id
}
}
}
`
describe('shout and unshout posts', () => {
let currentUser, postAuthor
@ -28,9 +38,6 @@ describe('shout and unshout posts', () => {
driver,
neode: instance,
user: authenticatedUser,
cypherParams: {
currentUserId: authenticatedUser ? authenticatedUser.id : null,
},
}
},
})
@ -115,7 +122,7 @@ describe('shout and unshout posts', () => {
await expect(mutate({ mutation: shout, variables })).resolves.toMatchObject({
data: { shout: true },
})
await expect(query({ query: Post, variables })).resolves.toMatchObject({
await expect(query({ query: queryPost, variables })).resolves.toMatchObject({
data: { Post: [{ id: 'another-user-post-id', shoutedBy: [{ id: 'current-user-id' }] }] },
errors: undefined,
})
@ -142,7 +149,7 @@ describe('shout and unshout posts', () => {
await expect(mutate({ mutation: shout, variables })).resolves.toMatchObject({
data: { shout: false },
})
await expect(query({ query: Post, variables })).resolves.toMatchObject({
await expect(query({ query: queryPost, variables })).resolves.toMatchObject({
data: { Post: [{ id: 'current-user-post-id', shoutedBy: [] }] },
errors: undefined,
})
@ -184,7 +191,7 @@ describe('shout and unshout posts', () => {
await expect(mutate({ mutation: unshout, variables })).resolves.toMatchObject({
data: { unshout: true },
})
await expect(query({ query: Post, variables })).resolves.toMatchObject({
await expect(query({ query: queryPost, variables })).resolves.toMatchObject({
data: { Post: [{ id: 'posted-by-another-user', shoutedBy: [] }] },
errors: undefined,
})

View File

@ -4,10 +4,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import Factory, { cleanDatabase } from '@db/factories'
import { getDriver } from '@db/neo4j'
import { CreateSocialMedia } from '@graphql/queries/CreateSocialMedia'
import { DeleteSocialMedia } from '@graphql/queries/DeleteSocialMedia'
import { UpdateSocialMedia } from '@graphql/queries/UpdateSocialMedia'
import createServer from '@src/server'
@ -84,16 +84,24 @@ describe('SocialMedia', () => {
})
describe('create social media', () => {
let variables
let mutation, variables
beforeEach(() => {
mutation = gql`
mutation ($url: String!) {
CreateSocialMedia(url: $url) {
id
url
}
}
`
variables = { url }
})
describe('unauthenticated', () => {
it('throws authorization error', async () => {
const user = null
const result = await socialMediaAction(user, CreateSocialMedia, variables)
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0]).toHaveProperty('message', 'Not Authorized!')
})
@ -107,19 +115,21 @@ describe('SocialMedia', () => {
})
it('creates social media with the given url', async () => {
await expect(socialMediaAction(user, CreateSocialMedia, variables)).resolves.toMatchObject({
data: {
CreateSocialMedia: {
id: expect.any(String),
url,
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining({
data: {
CreateSocialMedia: {
id: expect.any(String),
url,
},
},
},
})
}),
)
})
it('rejects an empty string as url', async () => {
variables = { url: '' }
const result = await socialMediaAction(user, CreateSocialMedia, variables)
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0].message).toEqual(
expect.stringContaining('"url" is not allowed to be empty'),
@ -128,7 +138,7 @@ describe('SocialMedia', () => {
it('rejects invalid urls', async () => {
variables = { url: 'not-a-url' }
const result = await socialMediaAction(user, CreateSocialMedia, variables)
const result = await socialMediaAction(user, mutation, variables)
expect(result.errors[0].message).toEqual(
expect.stringContaining('"url" must be a valid uri'),
@ -137,13 +147,28 @@ describe('SocialMedia', () => {
})
describe('ownedBy', () => {
beforeEach(() => {
mutation = gql`
mutation ($url: String!) {
CreateSocialMedia(url: $url) {
url
ownedBy {
name
}
}
}
`
})
it('resolves', async () => {
const user = someUser
await expect(socialMediaAction(user, CreateSocialMedia, variables)).resolves.toMatchObject({
data: {
CreateSocialMedia: { url, ownedBy: { name: 'Kalle Blomqvist' } },
},
})
await expect(socialMediaAction(user, mutation, variables)).resolves.toEqual(
expect.objectContaining({
data: {
CreateSocialMedia: { url, ownedBy: { name: 'Kalle Blomqvist' } },
},
}),
)
})
})
})

View File

@ -6,12 +6,12 @@
/* eslint-disable promise/prefer-await-to-callbacks */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable jest/unbound-method */
import gql from 'graphql-tag'
import { verify } from 'jsonwebtoken'
import { categories } from '@constants/categories'
import Factory, { cleanDatabase } from '@db/factories'
import { changePassword } from '@graphql/queries/changePassword'
import { currentUser } from '@graphql/queries/currentUser'
import { login } from '@graphql/queries/login'
import { saveCategorySettings } from '@graphql/queries/saveCategorySettings'
import { decode } from '@jwt/decode'
@ -86,8 +86,24 @@ afterEach(async () => {
})
describe('currentUser', () => {
const currentUserQuery = gql`
{
currentUser {
id
slug
name
avatar {
url
}
email
role
activeCategories
}
}
`
const respondsWith = async (expected) => {
await expect(query({ query: currentUser, variables })).resolves.toMatchObject(expected)
await expect(query({ query: currentUserQuery, variables })).resolves.toMatchObject(expected)
}
describe('unauthenticated', () => {
@ -195,7 +211,7 @@ describe('currentUser', () => {
})
it('returns only the saved active categories', async () => {
const result = await query({ query: currentUser, variables })
const result = await query({ query: currentUserQuery, variables })
expect(result.data?.currentUser.activeCategories).toHaveLength(4)
expect(result.data?.currentUser.activeCategories).toContain('cat1')
expect(result.data?.currentUser.activeCategories).toContain('cat3')

View File

@ -1,9 +1,12 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { AuthenticationError } from 'apollo-server'
import bcrypt from 'bcryptjs'
import { neo4jgraphql } from 'neo4j-graphql-js'
import { getNeode } from '@db/neo4j'
import { encode } from '@jwt/encode'
@ -15,21 +18,8 @@ const neode = getNeode()
export default {
Query: {
currentUser: async (_object, _params, context: Context, _resolveInfo) => {
if (!context.user) {
throw new Error('You must be logged in')
}
const [user] = (
await context.database.query({
query: `
MATCH (user:User {id: $user.id})-[:PRIMARY_EMAIL]->(e:EmailAddress)
RETURN user {.*, email: e.email}
`,
variables: { user: context.user },
})
).records.map((record) => record.get('user'))
return user
},
currentUser: async (object, params, context, resolveInfo) =>
neo4jgraphql(object, { id: context.user.id }, context, resolveInfo),
},
Mutation: {
login: async (_, { email, password }, context: Context) => {

View File

@ -3,6 +3,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import gql from 'graphql-tag'
import { categories } from '@constants/categories'
import pubsubContext from '@context/pubsub'
import Factory, { cleanDatabase } from '@db/factories'
@ -13,8 +15,6 @@ import { saveCategorySettings } from '@graphql/queries/saveCategorySettings'
import { setTrophyBadgeSelected } from '@graphql/queries/setTrophyBadgeSelected'
import { switchUserRole } from '@graphql/queries/switchUserRole'
import { updateOnlineStatus } from '@graphql/queries/updateOnlineStatus'
import { UpdateUser } from '@graphql/queries/UpdateUser'
import { UserEmailNotificationSettings, User as userQuery } from '@graphql/queries/User'
import type { ApolloTestSetup } from '@root/test/helpers'
import { createApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
@ -63,12 +63,21 @@ afterEach(async () => {
describe('User', () => {
describe('query by email address', () => {
let userQuery
beforeEach(async () => {
const user = await Factory.build('user', {
id: 'user',
role: 'user',
})
authenticatedUser = await user.toJson()
userQuery = gql`
query ($email: String) {
User(email: $email) {
name
}
}
`
variables = {
email: 'any-email-address@example.org',
}
@ -122,7 +131,35 @@ describe('User', () => {
})
describe('UpdateUser', () => {
let updateUserMutation
beforeEach(async () => {
updateUserMutation = gql`
mutation (
$id: ID!
$name: String
$termsAndConditionsAgreedVersion: String
$locationName: String # empty string '' sets it to null
) {
UpdateUser(
id: $id
name: $name
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
locationName: $locationName
) {
id
name
termsAndConditionsAgreedVersion
termsAndConditionsAgreedAt
locationName
location {
name
nameDE
nameEN
}
}
}
`
variables = {
id: 'u47',
name: 'John Doughnut',
@ -159,10 +196,8 @@ describe('UpdateUser', () => {
})
it('is not allowed to change other user accounts', async () => {
await expect(mutate({ mutation: UpdateUser, variables })).resolves.toMatchObject({
data: { UpdateUser: null },
errors: [{ message: 'Not Authorized!' }],
})
const { errors } = await mutate({ mutation: updateUserMutation, variables })
expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!')
})
})
@ -181,7 +216,9 @@ describe('UpdateUser', () => {
},
errors: undefined,
}
await expect(mutate({ mutation: UpdateUser, variables })).resolves.toMatchObject(expected)
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
expected,
)
})
describe('given a new agreed version of terms and conditions', () => {
@ -199,7 +236,9 @@ describe('UpdateUser', () => {
errors: undefined,
}
await expect(mutate({ mutation: UpdateUser, variables })).resolves.toMatchObject(expected)
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
@ -218,7 +257,9 @@ describe('UpdateUser', () => {
errors: undefined,
}
await expect(mutate({ mutation: UpdateUser, variables })).resolves.toMatchObject(expected)
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject(
expected,
)
})
})
@ -227,7 +268,7 @@ describe('UpdateUser', () => {
...variables,
termsAndConditionsAgreedVersion: 'invalid version format',
}
const { errors } = await mutate({ mutation: UpdateUser, variables })
const { errors } = await mutate({ mutation: updateUserMutation, variables })
expect(errors?.[0]).toHaveProperty('message', 'Invalid version format!')
})
@ -235,7 +276,7 @@ describe('UpdateUser', () => {
describe('change location to "Hamburg, New Jersey, United States"', () => {
it('has updated location to "Hamburg, New Jersey, United States"', async () => {
variables = { ...variables, locationName: 'Hamburg, New Jersey, United States' }
await expect(mutate({ mutation: UpdateUser, variables })).resolves.toMatchObject({
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: {
UpdateUser: {
locationName: 'Hamburg, New Jersey, United States',
@ -254,7 +295,7 @@ describe('UpdateUser', () => {
describe('change location to unset location', () => {
it('has updated location to unset location', async () => {
variables = { ...variables, locationName: '' }
await expect(mutate({ mutation: UpdateUser, variables })).resolves.toMatchObject({
await expect(mutate({ mutation: updateUserMutation, variables })).resolves.toMatchObject({
data: {
UpdateUser: {
locationName: null,
@ -507,10 +548,15 @@ describe('switch user role', () => {
id: 'user',
role: 'admin',
}
await expect(mutate({ mutation: switchUserRole, variables })).resolves.toMatchObject({
data: { switchUserRole: null },
errors: [{ message: 'Not Authorized!' }],
})
await expect(mutate({ mutation: switchUserRole, variables })).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})
@ -552,6 +598,33 @@ describe('switch user role', () => {
})
let anotherUser
const emailNotificationSettingsQuery = gql`
query ($id: ID!) {
User(id: $id) {
emailNotificationSettings {
type
settings {
name
value
}
}
}
}
`
const emailNotificationSettingsMutation = gql`
mutation ($id: ID!, $emailNotificationSettings: [EmailNotificationSettingsInput]!) {
UpdateUser(id: $id, emailNotificationSettings: $emailNotificationSettings) {
emailNotificationSettings {
type
settings {
name
value
}
}
}
}
`
describe('emailNotificationSettings', () => {
beforeEach(async () => {
@ -571,11 +644,16 @@ describe('emailNotificationSettings', () => {
authenticatedUser = await anotherUser.toJson()
const targetUser = await user.toJson()
await expect(
query({ query: UserEmailNotificationSettings, variables: { id: targetUser.id } }),
).resolves.toMatchObject({
data: { User: [null] },
errors: [{ message: 'Not Authorized!' }],
})
query({ query: emailNotificationSettingsQuery, variables: { id: targetUser.id } }),
).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})
@ -584,13 +662,112 @@ describe('emailNotificationSettings', () => {
authenticatedUser = await user.toJson()
await expect(
query({
query: UserEmailNotificationSettings,
query: emailNotificationSettingsQuery,
variables: { id: authenticatedUser?.id },
}),
).resolves.toMatchObject({
data: {
User: [
{
).resolves.toEqual(
expect.objectContaining({
data: {
User: [
{
emailNotificationSettings: [
{
type: 'post',
settings: [
{
name: 'commentOnObservedPost',
value: true,
},
{
name: 'mention',
value: true,
},
{
name: 'followingUsers',
value: true,
},
{
name: 'postInGroup',
value: true,
},
],
},
{
type: 'chat',
settings: [
{
name: 'chatMessage',
value: true,
},
],
},
{
type: 'group',
settings: [
{
name: 'groupMemberJoined',
value: true,
},
{
name: 'groupMemberLeft',
value: true,
},
{
name: 'groupMemberRemoved',
value: true,
},
{
name: 'groupMemberRoleChanged',
value: true,
},
],
},
],
},
],
},
}),
)
})
})
})
describe('mutate the field', () => {
const emailNotificationSettings = [{ name: 'mention', value: false }]
describe('as another user', () => {
it('throws an error', async () => {
authenticatedUser = await anotherUser.toJson()
const targetUser = await user.toJson()
await expect(
mutate({
mutation: emailNotificationSettingsMutation,
variables: { id: targetUser.id, emailNotificationSettings },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})
describe('as self', () => {
it('updates the emailNotificationSettings', async () => {
authenticatedUser = (await user.toJson()) as DecodedUser
await expect(
mutate({
mutation: emailNotificationSettingsMutation,
variables: { id: authenticatedUser.id, emailNotificationSettings },
}),
).resolves.toEqual(
expect.objectContaining({
data: {
UpdateUser: {
emailNotificationSettings: [
{
type: 'post',
@ -601,7 +778,7 @@ describe('emailNotificationSettings', () => {
},
{
name: 'mention',
value: true,
value: false,
},
{
name: 'followingUsers',
@ -645,99 +822,9 @@ describe('emailNotificationSettings', () => {
},
],
},
],
},
})
})
})
})
describe('mutate the field', () => {
const emailNotificationSettings = [{ name: 'mention', value: false }]
describe('as another user', () => {
it('throws an error', async () => {
authenticatedUser = await anotherUser.toJson()
const targetUser = await user.toJson()
await expect(
mutate({
mutation: UpdateUser,
variables: { id: targetUser.id, emailNotificationSettings },
}),
).resolves.toMatchObject({
data: { UpdateUser: null },
errors: [{ message: 'Not Authorized!' }],
})
})
})
describe('as self', () => {
it('updates the emailNotificationSettings', async () => {
authenticatedUser = (await user.toJson()) as DecodedUser
await expect(
mutate({
mutation: UpdateUser,
variables: { id: authenticatedUser.id, emailNotificationSettings },
}),
).resolves.toMatchObject({
data: {
UpdateUser: {
emailNotificationSettings: [
{
type: 'post',
settings: [
{
name: 'commentOnObservedPost',
value: true,
},
{
name: 'mention',
value: false,
},
{
name: 'followingUsers',
value: true,
},
{
name: 'postInGroup',
value: true,
},
],
},
{
type: 'chat',
settings: [
{
name: 'chatMessage',
value: true,
},
],
},
{
type: 'group',
settings: [
{
name: 'groupMemberJoined',
value: true,
},
{
name: 'groupMemberLeft',
value: true,
},
{
name: 'groupMemberRemoved',
value: true,
},
{
name: 'groupMemberRoleChanged',
value: true,
},
],
},
],
},
},
})
}),
)
})
})
})
@ -773,10 +860,15 @@ describe('save category settings', () => {
})
it('throws an error', async () => {
await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toMatchObject({
data: { saveCategorySettings: null },
errors: [{ message: 'Not Authorized!' }],
})
await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})
@ -785,6 +877,14 @@ describe('save category settings', () => {
authenticatedUser = await user.toJson()
})
const userQuery = gql`
query ($id: ID) {
User(id: $id) {
activeCategories
}
}
`
describe('no categories saved', () => {
it('returns true for active categories mutation', async () => {
await expect(mutate({ mutation: saveCategorySettings, variables })).resolves.toEqual(
@ -802,15 +902,17 @@ describe('save category settings', () => {
it('returns the active categories when user is queried', async () => {
await expect(
query({ query: userQuery, variables: { id: authenticatedUser?.id } }),
).resolves.toMatchObject({
data: {
User: [
{
activeCategories: expect.arrayContaining(['cat1', 'cat3', 'cat5']),
},
],
},
})
).resolves.toEqual(
expect.objectContaining({
data: {
User: [
{
activeCategories: expect.arrayContaining(['cat1', 'cat3', 'cat5']),
},
],
},
}),
)
})
})
})
@ -842,21 +944,23 @@ describe('save category settings', () => {
it('returns the new active categories when user is queried', async () => {
await expect(
query({ query: userQuery, variables: { id: authenticatedUser?.id } }),
).resolves.toMatchObject({
data: {
User: [
{
activeCategories: expect.arrayContaining([
'cat10',
'cat11',
'cat12',
'cat8',
'cat9',
]),
},
],
},
})
).resolves.toEqual(
expect.objectContaining({
data: {
User: [
{
activeCategories: expect.arrayContaining([
'cat10',
'cat11',
'cat12',
'cat8',
'cat9',
]),
},
],
},
}),
)
})
})
})
@ -880,10 +984,15 @@ describe('updateOnlineStatus', () => {
})
it('throws an error', async () => {
await expect(mutate({ mutation: updateOnlineStatus, variables })).resolves.toMatchObject({
data: null,
errors: [{ message: 'Not Authorized!' }],
})
await expect(mutate({ mutation: updateOnlineStatus, variables })).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})
@ -1013,10 +1122,15 @@ describe('setTrophyBadgeSelected', () => {
mutation: setTrophyBadgeSelected,
variables: { slot: 0, badgeId: 'trophy_bear' },
}),
).resolves.toMatchObject({
data: { setTrophyBadgeSelected: null },
errors: [{ message: 'Not Authorized!' }],
})
).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})
@ -1386,10 +1500,15 @@ describe('resetTrophyBadgesSelected', () => {
})
it('throws an error', async () => {
await expect(mutate({ mutation: resetTrophyBadgesSelected })).resolves.toMatchObject({
data: { resetTrophyBadgesSelected: null },
errors: [{ message: 'Not Authorized!' }],
})
await expect(mutate({ mutation: resetTrophyBadgesSelected })).resolves.toEqual(
expect.objectContaining({
errors: [
expect.objectContaining({
message: 'Not Authorized!',
}),
],
}),
)
})
})

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-call */
@ -352,11 +353,14 @@ export default {
session.close()
}
},
updateOnlineStatus: async (_object, args, context: Context, _resolveInfo) => {
updateOnlineStatus: async (_object, args, context, _resolveInfo) => {
const { status } = args
const {
user: { id },
} = context
const CYPHER_AWAY = `
MATCH (user:User {id: $user.id})
MATCH (user:User {id: $id})
WITH user,
CASE user.lastOnlineStatus
WHEN 'away' THEN user.awaySince
@ -366,14 +370,16 @@ export default {
SET user.lastOnlineStatus = $status
`
const CYPHER_ONLINE = `
MATCH (user:User {id: $user.id})
MATCH (user:User {id: $id})
SET user.awaySince = null
SET user.lastOnlineStatus = $status
`
await context.database.write({
query: status === 'away' ? CYPHER_AWAY : CYPHER_ONLINE,
variables: { user: context.user, status },
// Last Online Time is saved as `lastActiveAt`
const session = context.driver.session()
await session.writeTransaction((transaction) => {
// return transaction.run(status === 'away' ? CYPHER_AWAY : CYPHER_ONLINE, { id, status })
return transaction.run(status === 'away' ? CYPHER_AWAY : CYPHER_ONLINE, { id, status })
})
return true
@ -457,18 +463,6 @@ export default {
},
},
User: {
activeCategories: async (parent, _args, context: Context, _resolveInfo) => {
return (
await context.database.query({
query: `
MATCH (category:Category)
WHERE NOT ((:User{id: $user.id})-[:NOT_INTERESTED_IN]->(category))
RETURN collect(category.id) as categories
`,
variables: { user: parent },
})
).records.map((record) => record.get('categories'))[0]
},
inviteCodes: async (_parent, _args, context: Context, _resolveInfo) => {
return (
await context.database.query({
@ -482,7 +476,7 @@ export default {
})
).records.map((record) => record.get('inviteCodes'))
},
emailNotificationSettings: (parent, _params, _context, _resolveInfo) => {
emailNotificationSettings: async (parent, _params, _context, _resolveInfo) => {
return [
{
type: 'post',

View File

@ -2,12 +2,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import gql from 'graphql-tag'
import { cleanDatabase } from '@db/factories'
import { blockedUsers } from '@graphql/queries/blockedUsers'
import { blockUser } from '@graphql/queries/blockUser'
import { Post } from '@graphql/queries/Post'
import { unblockUser } from '@graphql/queries/unblockUser'
import { User } from '@graphql/queries/User'
import { createApolloTestSetup } from '@root/test/helpers'
import type { ApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
@ -155,37 +155,56 @@ describe('blockUser', () => {
it('unfollows the user when blocking', async () => {
await currentUser.relateTo(blockedUser, 'following')
await expect(query({ query: User, variables: { id: 'u2' } })).resolves.toMatchObject({
const queryUser = gql`
query {
User(id: "u2") {
id
isBlocked
followedByCurrentUser
}
}
`
await expect(query({ query: queryUser })).resolves.toMatchObject({
data: { User: [{ id: 'u2', isBlocked: false, followedByCurrentUser: true }] },
})
await mutate({ mutation: blockUser, variables: { id: 'u2' } })
await expect(query({ query: User, variables: { id: 'u2' } })).resolves.toMatchObject({
await expect(query({ query: queryUser })).resolves.toMatchObject({
data: { User: [{ id: 'u2', isBlocked: true, followedByCurrentUser: false }] },
})
})
describe('given both the current user and the to-be-blocked user write a post', () => {
let postQuery
beforeEach(async () => {
const post1 = await database.neode.create('Post', {
id: 'p12',
title: 'A post written by the current user',
content: 'content',
})
const post2 = await database.neode.create('Post', {
id: 'p23',
title: 'A post written by the blocked user',
content: 'content',
})
await Promise.all([
post1.relateTo(currentUser, 'author'),
post2.relateTo(blockedUser, 'author'),
])
postQuery = gql`
query {
Post(orderBy: createdAt_asc) {
id
title
author {
id
name
}
}
}
`
})
const bothPostsAreInTheNewsfeed = async () => {
await expect(
query({ query: Post, variables: { orderBy: 'createdAt_asc' } }),
).resolves.toMatchObject({
await expect(query({ query: postQuery })).resolves.toMatchObject({
data: {
Post: [
{
@ -219,9 +238,7 @@ describe('blockUser', () => {
// TODO: clarify proper behaviour
it("the blocked user's post still shows up in the newsfeed of the current user", async () => {
await expect(
query({ query: Post, variables: { orderBy: 'createdAt_asc' } }),
).resolves.toMatchObject({
await expect(query({ query: postQuery })).resolves.toMatchObject({
data: {
Post: [
{
@ -245,81 +262,6 @@ describe('blockUser', () => {
})
})
})
describe('if the current user blocks and mutes the other user', () => {
beforeEach(async () => {
await currentUser.relateTo(blockedUser, 'blocked')
await currentUser.relateTo(blockedUser, 'muted')
})
it('the muted+blocked user post is still accessible by direct id lookup', async () => {
await expect(query({ query: Post, variables: { id: 'p23' } })).resolves.toMatchObject(
{
data: {
Post: [
expect.objectContaining({
id: 'p23',
title: 'A post written by the blocked user',
}),
],
},
},
)
})
describe('and the blocked+muted user has a pinned post', () => {
beforeEach(async () => {
const pinnedPost = await database.neode.create('Post', {
id: 'p-pinned',
title: 'A pinned post by the blocked user',
content: 'pinned content',
pinned: true,
})
await pinnedPost.relateTo(blockedUser, 'author')
})
it('the pinned post is still accessible by id', async () => {
await expect(
query({ query: Post, variables: { id: 'p-pinned' } }),
).resolves.toMatchObject({
data: {
Post: [
expect.objectContaining({
id: 'p-pinned',
title: 'A pinned post by the blocked user',
pinned: true,
}),
],
},
})
})
it('the pinned post shows up in the post list', async () => {
await expect(
query({ query: Post, variables: { orderBy: 'createdAt_asc' } }),
).resolves.toMatchObject({
data: {
Post: expect.arrayContaining([
expect.objectContaining({
id: 'p-pinned',
pinned: true,
}),
]),
},
})
})
it('the non-pinned post from the muted+blocked user is still hidden in the feed', async () => {
const result = await query({
query: Post,
variables: { orderBy: 'createdAt_asc' },
})
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const postIds = result.data?.Post.map((p) => p.id)
expect(postIds).not.toContain('p23')
})
})
})
})
describe('from the perspective of the blocked user', () => {
@ -334,21 +276,19 @@ describe('blockUser', () => {
})
it("the current user's post will show up in the newsfeed of the blocked user", async () => {
await expect(
query({ query: Post, variables: { orderBy: 'createdAt_asc' } }),
).resolves.toMatchObject({
await expect(query({ query: postQuery })).resolves.toMatchObject({
data: {
Post: expect.arrayContaining([
expect.objectContaining({
{
id: 'p23',
title: 'A post written by the blocked user',
author: { name: 'Blocked User', id: 'u2' },
}),
expect.objectContaining({
},
{
id: 'p12',
title: 'A post written by the current user',
author: { name: 'Current User', id: 'u1' },
}),
},
]),
},
})

View File

@ -1,9 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import gql from 'graphql-tag'
import Factory, { cleanDatabase } from '@db/factories'
import { queryLocations } from '@graphql/queries/queryLocations'
import { UpdateUser } from '@graphql/queries/UpdateUser'
import type { ApolloTestSetup } from '@root/test/helpers'
import { createApolloTestSetup } from '@root/test/helpers'
import type { Context } from '@src/context'
@ -18,6 +19,13 @@ let query: any // eslint-disable-line @typescript-eslint/no-explicit-any
let database: ApolloTestSetup['database']
let server: ApolloTestSetup['server']
const updateUserMutation = gql`
mutation ($id: ID!, $name: String!, $locationName: String) {
UpdateUser(id: $id, name: $name, locationName: $locationName) {
locationName
}
}
`
const newlyCreatedNodesWithLocales = [
{
city: {
@ -195,7 +203,7 @@ describe('userMiddleware', () => {
name: 'Updating user',
locationName: 'Welzheim, Baden-Württemberg, Germany',
}
await mutate({ mutation: UpdateUser, variables })
await mutate({ mutation: updateUserMutation, variables })
const locations = await database.neode.cypher(
`MATCH (city:Location)-[:IS_IN]->(district:Location)-[:IS_IN]->(state:Location)-[:IS_IN]->(country:Location) return city {.*}, state {.*}, country {.*}`,
{},

View File

@ -3,14 +3,13 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import { createTestClient } from 'apollo-server-testing'
import gql from 'graphql-tag'
import { cleanDatabase } from '@db/factories'
import { getNeode, getDriver } from '@db/neo4j'
import { mutedUsers } from '@graphql/queries/mutedUsers'
import { muteUser } from '@graphql/queries/muteUser'
import { Post } from '@graphql/queries/Post'
import { unmuteUser } from '@graphql/queries/unmuteUser'
import { User } from '@graphql/queries/User'
import createServer from '@src/server'
const driver = getDriver()
@ -153,68 +152,85 @@ describe('muteUser', () => {
it('unfollows the user', async () => {
await currentUser.relateTo(mutedUser, 'following')
const queryUser = gql`
query {
User(id: "u2") {
id
isMuted
followedByCurrentUser
}
}
`
const { query } = createTestClient(server)
await expect(query({ query: User, variables: { id: 'u2' } })).resolves.toMatchObject({
data: {
User: expect.arrayContaining([
expect.objectContaining({ id: 'u2', isMuted: false, followedByCurrentUser: true }),
]),
},
})
await expect(query({ query: queryUser })).resolves.toEqual(
expect.objectContaining({
data: { User: [{ id: 'u2', isMuted: false, followedByCurrentUser: true }] },
}),
)
await muteAction({ id: 'u2' })
await expect(query({ query: User, variables: { id: 'u2' } })).resolves.toMatchObject({
data: {
User: expect.arrayContaining([
expect.objectContaining({ id: 'u2', isMuted: true, followedByCurrentUser: false }),
]),
},
})
await expect(query({ query: queryUser })).resolves.toEqual(
expect.objectContaining({
data: { User: [{ id: 'u2', isMuted: true, followedByCurrentUser: false }] },
}),
)
})
describe('given both the current user and the to-be-muted user write a post', () => {
let postQuery
beforeEach(async () => {
const post1 = await neode.create('Post', {
id: 'p12',
title: 'A post written by the current user',
content: 'content',
})
const post2 = await neode.create('Post', {
id: 'p23',
title: 'A post written by the muted user',
content: 'content',
})
await Promise.all([
post1.relateTo(currentUser, 'author'),
post2.relateTo(mutedUser, 'author'),
])
postQuery = gql`
query {
Post(orderBy: createdAt_asc) {
id
title
author {
id
name
}
}
}
`
})
const bothPostsAreInTheNewsfeed = async () => {
const { query } = createTestClient(server)
await expect(
query({ query: Post, variables: { orderBy: 'createdAt_asc' } }),
).resolves.toMatchObject({
data: {
Post: expect.arrayContaining([
expect.objectContaining({
id: 'p12',
title: 'A post written by the current user',
author: {
name: 'Current User',
id: 'u1',
await expect(query({ query: postQuery })).resolves.toEqual(
expect.objectContaining({
data: {
Post: [
{
id: 'p12',
title: 'A post written by the current user',
author: {
name: 'Current User',
id: 'u1',
},
},
}),
expect.objectContaining({
id: 'p23',
title: 'A post written by the muted user',
author: {
name: 'Muted User',
id: 'u2',
{
id: 'p23',
title: 'A post written by the muted user',
author: {
name: 'Muted User',
id: 'u2',
},
},
}),
]),
},
})
],
},
}),
)
}
describe('from the perspective of the current user', () => {
@ -227,93 +243,20 @@ describe('muteUser', () => {
it("the muted user's post won't show up in the newsfeed of the current user", async () => {
const { query } = createTestClient(server)
await expect(
query({ query: Post, variables: { orderBy: 'createdAt_asc' } }),
).resolves.toMatchObject({
data: {
Post: [
expect.objectContaining({
id: 'p12',
title: 'A post written by the current user',
author: { name: 'Current User', id: 'u1' },
}),
],
},
})
})
it("the muted user's post is still accessible by direct id lookup", async () => {
const { query } = createTestClient(server)
await expect(query({ query: Post, variables: { id: 'p23' } })).resolves.toMatchObject(
{
await expect(query({ query: postQuery })).resolves.toEqual(
expect.objectContaining({
data: {
Post: [
expect.objectContaining({
id: 'p23',
title: 'A post written by the muted user',
}),
{
id: 'p12',
title: 'A post written by the current user',
author: { name: 'Current User', id: 'u1' },
},
],
},
},
}),
)
})
describe('but the muted user has a pinned post', () => {
beforeEach(async () => {
const pinnedPost = await neode.create('Post', {
id: 'p-pinned',
title: 'A pinned post by the muted user',
content: 'pinned content',
pinned: true,
})
await pinnedPost.relateTo(mutedUser, 'author')
})
it('the pinned post still shows up in the post list', async () => {
const { query } = createTestClient(server)
await expect(
query({ query: Post, variables: { orderBy: 'createdAt_asc' } }),
).resolves.toMatchObject({
data: {
Post: expect.arrayContaining([
expect.objectContaining({
id: 'p-pinned',
title: 'A pinned post by the muted user',
pinned: true,
}),
]),
},
})
})
it('the pinned post is accessible by id', async () => {
const { query } = createTestClient(server)
await expect(
query({ query: Post, variables: { id: 'p-pinned' } }),
).resolves.toMatchObject({
data: {
Post: [
expect.objectContaining({
id: 'p-pinned',
title: 'A pinned post by the muted user',
pinned: true,
}),
],
},
})
})
it('the non-pinned post from the muted user is still hidden in the feed', async () => {
const { query } = createTestClient(server)
const result = await query({
query: Post,
variables: { orderBy: 'createdAt_asc' },
})
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const postIds = result.data?.Post.map((p) => p.id)
expect(postIds).not.toContain('p23')
})
})
})
})
@ -330,24 +273,24 @@ describe('muteUser', () => {
it("the current user's post will show up in the newsfeed of the muted user", async () => {
const { query } = createTestClient(server)
await expect(
query({ query: Post, variables: { orderBy: 'createdAt_asc' } }),
).resolves.toMatchObject({
data: {
Post: expect.arrayContaining([
expect.objectContaining({
id: 'p23',
title: 'A post written by the muted user',
author: { name: 'Muted User', id: 'u2' },
}),
expect.objectContaining({
id: 'p12',
title: 'A post written by the current user',
author: { name: 'Current User', id: 'u1' },
}),
]),
},
})
await expect(query({ query: postQuery })).resolves.toEqual(
expect.objectContaining({
data: {
Post: expect.arrayContaining([
{
id: 'p23',
title: 'A post written by the muted user',
author: { name: 'Muted User', id: 'u2' },
},
{
id: 'p12',
title: 'A post written by the current user',
author: { name: 'Current User', id: 'u1' },
},
]),
},
}),
)
})
})
})

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { makeAugmentedSchema } from 'neo4j-graphql-js'
import typeDefs from '@graphql/types/index'

View File

@ -1,14 +0,0 @@
# directive @MutationMeta on FIELD_DEFINITION
# directive @isAuthenticated on FIELD_DEFINITION
# directive @hasRole on FIELD_DEFINITION
# directive @hasScope on FIELD_DEFINITION
# directive @additionalLabels on FIELD_DEFINITION
directive @cypher(statement: String) on FIELD_DEFINITION
directive @relation(
name: String
from: String
to: String
direction: String
) on FIELD_DEFINITION | OBJECT
directive @neo4j_ignore on FIELD_DEFINITION

View File

@ -2,4 +2,4 @@ enum EmailNotificationSettingsType {
post
chat
group
}
}

View File

@ -4,4 +4,4 @@ enum Emotion {
happy
angry
funny
}
}

View File

@ -1,4 +1,4 @@
enum ShoutTypeEnum {
Post
Comment
}
}

View File

@ -2,4 +2,4 @@ enum Visibility {
public
friends
private
}
}

Some files were not shown because too many files have changed in this diff Show More