diff --git a/.github/workflows/cypress-tests.yml b/.github/workflows/cypress-tests.yml
index 7846d23905..87f160fb1e 100644
--- a/.github/workflows/cypress-tests.yml
+++ b/.github/workflows/cypress-tests.yml
@@ -25,9 +25,34 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+ # Configure proper Docker layer caching
+ - name: Set up Docker Cache
+ uses: actions/cache@v3
+ with:
+ path: ${{ github.workspace }}/.buildx-cache
+ key: ${{ runner.os }}-buildx-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-buildx-
+
- name: Build and start Docker containers
run: |
+ # Build with proper cache configuration
+ docker buildx build \
+ --cache-from=type=local,src=${{ github.workspace }}/.buildx-cache \
+ --cache-to=type=local,dest=${{ github.workspace }}/.buildx-cache-new,mode=max \
+ --load \
+ -f server/Dockerfile \
+ ./server
+
+ # Move cache to prevent cache growth
+ rm -rf ${{ github.workspace }}/.buildx-cache
+ mv ${{ github.workspace }}/.buildx-cache-new ${{ github.workspace }}/.buildx-cache
+
+ # Start containers
docker compose -f docker-compose.yml -f docker-compose.test.yml --env-file test.env --profile postgres up -d --build
+ env:
+ DOCKER_BUILDKIT: 1
+ COMPOSE_DOCKER_CLI_BUILD: 1
- name: Health Check the Server http response
uses: jtalk/url-health-check-action@v4
diff --git a/.github/workflows/jest-server-test.yml b/.github/workflows/jest-server-test.yml
index eb695ad51d..940b647494 100644
--- a/.github/workflows/jest-server-test.yml
+++ b/.github/workflows/jest-server-test.yml
@@ -18,50 +18,83 @@ on:
jobs:
jest-run:
runs-on: ubuntu-latest
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: PdwPNS2mDN73Vfbc
+ POSTGRES_DB: polis-test
+ POSTGRES_PORT: 5432
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: ${{ env.POSTGRES_USER }}
+ POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
+ POSTGRES_DB: ${{ env.POSTGRES_DB }}
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d polis-test"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v4
- - name: Setup env
- run: |
- cp example.env .env
-
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Build and start Docker containers
- run: |
- docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile postgres up postgres -d --build
-
-
- - uses: actions/checkout@v3
-
- - name: Setup Node.js
- uses: actions/setup-node@v3
+ # Configure proper Docker layer caching
+ - name: Set up Docker Cache
+ uses: actions/cache@v3
with:
- node-version: "18"
- cache: "npm"
- cache-dependency-path: server/package-lock.json
+ path: ${{ github.workspace }}/.buildx-cache
+ key: ${{ runner.os }}-buildx-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-buildx-
+
+ - name: Copy test.env to .env and configure for GHA service
+ run: |
+ cp test.env .env
+ echo "POSTGRES_HOST=172.17.0.1" >> .env
+ echo "DATABASE_URL=postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@172.17.0.1:${{ env.POSTGRES_PORT }}/${{ env.POSTGRES_DB }}" >> .env
- - name: Setup env in server
- working-directory: server
+ - name: Build and start Docker containers (excluding Postgres)
run: |
- cp example.env .env
+ # Build with proper cache configuration
+ docker buildx build \
+ --cache-from=type=local,src=${{ github.workspace }}/.buildx-cache \
+ --cache-to=type=local,dest=${{ github.workspace }}/.buildx-cache-new,mode=max \
+ --load \
+ -f server/Dockerfile \
+ ./server
+
+ # Move cache to prevent cache growth
+ rm -rf ${{ github.workspace }}/.buildx-cache
+ mv ${{ github.workspace }}/.buildx-cache-new ${{ github.workspace }}/.buildx-cache
+
+ # Start containers - remove --profile postgres as GHA service handles postgres
+ docker compose -f docker-compose.yml -f docker-compose.test.yml up -d --build
+ env:
+ DOCKER_BUILDKIT: 1
+ COMPOSE_DOCKER_CLI_BUILD: 1
- - name: Install dependencies
- working-directory: server
- run: npm install
+ - name: Run database migrations
+ env:
+ # Construct DATABASE_URL for the migration script
+ # using the same host (172.17.0.1) and credentials as the application
+ DATABASE_URL: postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@172.17.0.1:${{ env.POSTGRES_PORT }}/${{ env.POSTGRES_DB }}
+ run: |
+ echo "Installing postgresql-client..."
+ sudo apt-get update -y
+ sudo apt-get install -y postgresql-client
+ echo "Running migrations..."
+ bash server/bin/run-migrations.sh
- - name: Build & start server
- working-directory: server
+ - name: Run Jest tests
run: |
- npm run build
- nohup npm run serve &
-
- - name: Jest test
- working-directory: server
- run: npm run test
+ docker exec polis-test-server-1 npm test
- name: Stop Docker containers
if: always()
- run: docker compose -f docker-compose.yml -f docker-compose.test.yml --env-file test.env --profile postgres down
+ run: docker compose -f docker-compose.yml -f docker-compose.test.yml --profile postgres down
diff --git a/.gitignore b/.gitignore
index 1658df67fe..fe2bc9685f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,5 @@ prod.env
xids.csv
preprod.env
.venv
+
+**/CLAUDE.local.md
diff --git a/client-participation/api/embed.js b/client-participation/api/embed.js
index e3c0b6067e..850d74e241 100644
--- a/client-participation/api/embed.js
+++ b/client-participation/api/embed.js
@@ -155,7 +155,7 @@
iframe.style.border = o.border || "1px solid #ccc";
iframe.style.borderRadius = o.border_radius || "4px";
iframe.style.padding = o.padding || "4px"; // 1px ensures that right border shows up on default wordpress theme
- iframe.style.backgroundColor = "white";
+ iframe.style.backgroundColor = "#333";
// iframe.style.backgroundColor = "rgb(247, 247, 247)";
iframe.id = id;
iframe.setAttribute("data-test-id", "polis-iframe");
diff --git a/client-participation/api/embedPreprod.js b/client-participation/api/embedPreprod.js
index c1c7dec7c7..4288da6cf8 100644
--- a/client-participation/api/embedPreprod.js
+++ b/client-participation/api/embedPreprod.js
@@ -155,7 +155,7 @@
iframe.style.border = o.border || "1px solid #ccc";
iframe.style.borderRadius = o.border_radius || "4px";
iframe.style.padding = o.padding || "4px"; // 1px ensures that right border shows up on default wordpress theme
- iframe.style.backgroundColor = "white";
+ iframe.style.backgroundColor = "#333";
// iframe.style.backgroundColor = "rgb(247, 247, 247)";
iframe.id = id;
parent.appendChild(iframe);
diff --git a/client-participation/api/polisHost.js b/client-participation/api/polisHost.js
index ee88cf03dd..8bb75ed8f0 100644
--- a/client-participation/api/polisHost.js
+++ b/client-participation/api/polisHost.js
@@ -34,6 +34,7 @@
iframe.height = o.height || 900;
iframe.style.border = "1px solid #ccc";
iframe.style.borderRadius = "4px";
+ iframe.style.backgroundColor = "#333";
parent.appendChild(iframe);
}
diff --git a/client-participation/js/strings.js b/client-participation/js/strings.js
index fe8d8518fe..52592ae6e8 100644
--- a/client-participation/js/strings.js
+++ b/client-participation/js/strings.js
@@ -36,6 +36,8 @@ var translations = {
it: require("./strings/it.js"),
// Japanese
ja: require("./strings/ja.js"),
+ // Korean
+ ko_kr: require("./strings/ko_kr.js"),
// Dutch
nl: require("./strings/nl.js"),
// Portuguese
@@ -90,6 +92,8 @@ preloadHelper.acceptLanguagePromise.then(function() {
_.extend(strings, translations.en_us);
} else if (languageCode.match(/^ja/)) {
_.extend(strings, translations.ja);
+ } else if (languageCode.match(/^ko-KR/)) {
+ _.extend(strings, translations.ko_kr);
} else if (
languageCode.match(/^zh-CN/) ||
languageCode.match(/^zh-SG/) ||
diff --git a/client-participation/js/strings/ko_kr.js b/client-participation/js/strings/ko_kr.js
new file mode 100644
index 0000000000..e81e4880f2
--- /dev/null
+++ b/client-participation/js/strings/ko_kr.js
@@ -0,0 +1,203 @@
+// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see .
+
+var s = {};
+
+// Text on the card
+
+s.participantHelpWelcomeText =
+ "새로운 집단 지성 토론장에 오신 것을 환영합니다 — 의견에 대해 투표하거나 의견을 작성하세요";
+
+s.agree = "찬성";
+s.disagree = "반대";
+s.pass = "패스 / 불확실";
+
+s.writePrompt ="해당 주제에 대한 관점이나 의견을 올려주세요.";
+s.anonPerson = "익명";
+s.importantCheckbox = "중요/의미있는";
+s.importantCheckboxDesc =
+ "이 의견이 특히 중요하거나 토론과 매우 관련이 있다고 생각하면 상자에 체크하세요. 투표와 관계없이 토론 분석에서 다른 투표에 비해 이 의견에 더 높은 우선순위를 부여합니다.";
+s.howImportantPrompt = "이 의견이 얼마나 중요한가요?";
+s.howImportantLow = "낮음";
+s.howImportantMedium = "중간";
+s.howImportantHigh = "높음";
+
+s.modSpam = "스팸";
+s.modOffTopic = "주제에서 벗어남";
+s.modImportant = "중요";
+s.modSubmitInitialState = "건너뛰기 (위의 것 중 하나도 아님), 다음 의견";
+s.modSubmit = "완료, 다음 의견";
+
+s.x_wrote = "작성:";
+s.x_tweeted = "트윗:";
+s.comments_remaining = "미투표 의견 {{num_comments}}개 남음";
+s.comments_remaining2 = "{{num_comments}}개의 미투표 의견";
+
+// Text about phasing
+
+s.noCommentsYet = "아직 의견이 없습니다.";
+s.noCommentsYetSoWrite = "의견을 올려 주세요.";
+s.noCommentsYetSoInvite =
+ "더 많은 사람을 초대하거나 의견을 추가해 보세요.";
+s.noCommentsYouVotedOnAll = "모든 의견에 투표하셨습니다.";
+s.noCommentsTryWritingOne =
+ "추가하고 싶은 것이 있다면, 의견을 작성해 보세요.";
+s.convIsClosed = "토론장이 종료되었습니다.";
+s.noMoreVotingAllowed = "더 이상 투표할 수 없습니다.";
+
+// For the visualization below
+
+s.group_123 = "그룹:";
+s.comment_123 = "의견:";
+s.majorityOpinion = "다수 의견";
+s.majorityOpinionShort = "다수";
+s.info = "정보";
+
+
+s.helpWhatAmISeeingTitle = "무엇을 보고 있나요?";
+s.helpWhatAmISeeing =
+ "당신은 파란색 원으로 표현되며, 당신의 관점을 공유하는 다른 사람들과 함께 그룹화됩니다.";
+s.heresHowGroupVoted = "그룹 {{GROUP_NUMBER}}의 투표 결과입니다:";
+s.one_person = "{{x}}명";
+s.x_people = "{{x}}명";
+s.acrossAllPtpts = "모든 참가자 중:";
+s.xPtptsSawThisComment = "명이 이 의견을 보았습니다";
+s.xOfThoseAgreed = "명의 참가자가 찬성했습니다";
+s.xOfthoseDisagreed = "명의 참가자가 반대했습니다";
+s.opinionGroups = "의견 그룹";
+s.topComments = "상위 의견";
+s.divisiveComments = "분열적인 의견";
+s.pctAgreed = "{{pct}}% 찬성";
+s.pctDisagreed = "{{pct}}% 반대";
+s.pctAgreedLong =
+ "의견 {{comment_id}}에 투표한 모든 사람 중 {{pct}}%가 찬성했습니다.";
+s.pctAgreedOfGroup = "그룹 {{group}}의 {{pct}}%가 찬성했습니다";
+s.pctDisagreedOfGroup = "그룹 {{group}}의 {{pct}}%가 반대했습니다";
+s.pctDisagreedLong =
+ "의견 {{comment_id}}에 투표한 모든 사람 중 {{pct}}%가 반대했습니다.";
+s.pctAgreedOfGroupLong =
+ "그룹 {{group}}에서 의견 {{comment_id}}에 투표한 사람 중 {{pct}}%가 찬성했습니다.";
+s.pctDisagreedOfGroupLong =
+ "그룹 {{group}}에서 의견 {{comment_id}}에 투표한 사람 중 {{pct}}%가 반대했습니다.";
+s.participantHelpGroupsText =
+ "당신은 파란색 원으로 표현되며, 당신의 관점을 공유하는 다른 사람들과 함께 그룹화됩니다.";
+s.participantHelpGroupsNotYetText =
+ "7명 이상 투표하면 그래프가 나타납니다";
+s.helpWhatAreGroupsDetail =
+ "
당신의 그룹이나 다른 그룹을 클릭하여 각 그룹의 의견을 탐색하세요.
다수 의견은 그룹 간에 가장 널리 공유되는 의견입니다.
";
+
+// Text about writing your own statement
+
+s.helpWhatDoIDoTitle = " 무엇을 해야 하나요?";
+s.helpWhatDoIDo =
+ "'찬성' 또는 '반대'를 클릭하여 다른 사람들의 의견에 투표하세요. 또는 의견을 작성하세요 (각각 하나의 아이디어로 유지하세요). 친구들을 토론에 초대하세요!";
+s.writeCommentHelpText =
+ "좋은 의견은 토론에 큰 도움이 됩니다. 좋은 의견을 올리는 팁.";
+s.helpWriteListIntro = "좋은 의견이란 무엇인가요?";
+s.helpWriteListStandalone = "독립적인 아이디어";
+s.helpWriteListRaisNew = "새로운 관점, 경험 또는 이슈";
+s.helpWriteListShort = "명확하고 간결한 표현 (140자로 제한)";
+s.tip = "팁:";
+s.commentWritingTipsHintsHeader = "의견 작성 팁";
+s.tipCharLimit = "의견은 {{char_limit}}자로 제한됩니다.";
+s.tipCommentsRandom =
+ "의견을 작성하면 투표 상자에 나타나게 됩니다. 투표 상자에 나타나는 의견의 순서는 무작위이며, 다른 사람들의 의견에 답장하는 것은 아닙니다.";
+s.tipOneIdea =
+ "여러 아이디어를 포함하는 긴 의견을 나누세요. 이렇게 하면 다른 사람들이 당신의 의견에 투표하기 쉬워집니다.";
+s.tipNoQuestions =
+ "의견은 질문 형태가 아니어야 합니다. 참가자들은 당신이 제시한 의견에 찬성하거나 반대할 것입니다.";
+s.commentTooLongByChars =
+ "의견 길이 제한을 {{CHARACTERS_COUNT}}자 초과했습니다.";
+s.submitComment = "제출";
+s.commentSent =
+ "의견이 제출되었습니다! 다른 참가자들만 당신의 의견을 보고 찬성하거나 반대할 수 있습니다.";
+
+// Error notices
+
+s.commentSendFailed = "의견을 제출하는 중 오류가 발생했습니다.";
+s.commentSendFailedEmpty =
+ "의견을 제출하는 중 오류가 발생했습니다 - 의견은 비어있지 않아야 합니다.";
+s.commentSendFailedTooLong =
+ "의견을 제출하는 중 오류가 발생했습니다 - 의견이 너무 깁니다.";
+s.commentSendFailedDuplicate =
+ "의견을 제출하는 중 오류가 발생했습니다 - 동일한 의견이 이미 존재합니다.";
+s.commentErrorDuplicate = "중복! 해당 의견이 이미 존재합니다.";
+s.commentErrorConversationClosed =
+ "이 토론은 종료되었습니다. 더 이상의 의견을 제출할 수 없습니다.";
+s.commentIsEmpty = "의견이 비어있습니다";
+s.commentIsTooLong = "의견이 너무 깁니다";
+s.hereIsNextStatement = "투표 성공. 위로 이동하여 다음 의견을 확인하세요.";
+
+// Text about connecting identity
+
+s.connectFacebook = "페이스북 연결";
+s.connectTwitter = "트위터 연결";
+s.connectToPostPrompt =
+ "의견을 제출하려면 신원을 연결하세요. 타임라인에 게시하지 않습니다.";
+s.connectToVotePrompt =
+ "투표하려면 신원을 연결하세요. 타임라인에 게시하지 않습니다.";
+s.socialConnectPrompt =
+ "시각화에서 친구와 팔로우하는 사람들을 보려면 선택적으로 연결하세요.";
+s.connectFbButton = "페이스북으로 연결";
+s.connectTwButton = "트위터로 연결";
+s.polis_err_reg_fb_verification_email_sent =
+ "이메일에서 확인 링크를 확인한 후 여기로 돌아와 계속하세요.";
+s.polis_err_reg_fb_verification_noemail_unverified =
+ "페이스북 계정이 확인되지 않았습니다. 페이스북에서 이메일 주소를 확인한 후 여기로 돌아와 계속하세요.";
+
+// Text for the third party translation that appears on the cards
+
+s.showTranslationButton = "서드파티 번역 활성화";
+s.hideTranslationButton = "번역 비활성화";
+s.thirdPartyTranslationDisclaimer = "서드파티에서 제공한 번역";
+
+// Text about notifications and subscriptions and embedding
+
+s.notificationsAlreadySubscribed =
+ "이 토론의 업데이트에 이미 구독하셨습니다.";
+s.notificationsGetNotified = "다른 의견에 대한 알림을 받으세요:";
+s.notificationsEnterEmail =
+ "다른 의견에 대한 알림을 받으려면 이메일 주소를 입력하세요:";
+s.labelEmail = "이메일";
+s.notificationsSubscribeButton = "알림 설정";
+s.notificationsSubscribeErrorAlert = "알림 설정 중 오류 발생";
+
+s.addPolisToYourSite =
+ "
";
+
+// Footer
+
+s.privacy = "개인정보";
+s.TOS = "이용약관";
+
+// Experimental features
+
+s.importantCheckbox = "이 의견은 중요합니다";
+s.howImportantPrompt = "이 의견이 얼마나 중요한가요?";
+s.howImportantLow = "낮음";
+s.howImportantMedium = "중간";
+s.howImportantHigh = "높음";
+s.tipStarred = "중요로 표시됨.";
+
+s.modSpam = "스팸";
+s.modOffTopic = "주제에서 벗어남";
+s.modImportant = "중요";
+s.modSubmitInitialState = "건너뛰기 (위의 것 중 하나도 아님), 다음 의견";
+s.modSubmit = "완료, 다음 의견";
+
+s.topic_good_01 = "탁구실에 대해 어떻게 해야 할까요?";
+s.topic_good_01_reason =
+ "개방형 질문으로, 누구나 이 질문에 대한 답에 의견을 가질 수 있습니다";
+s.topic_good_02 = "새로운 제안에 대해 어떻게 생각하시나요?";
+s.topic_good_02_reason =
+ "개방형 질문으로, 누구나 이 질문에 대한 답에 의견을 가질 수 있습니다";
+s.topic_good_03 = "생산성을 저하시키는 것이 있다고 생각하시나요?";
+
+s.topic_bad_01 = "모두 출시 준비 상태를 보고하세요";
+s.topic_bad_01_reason =
+ "다양한 팀의 사람들이 응답에 투표할 것이지만, 자신 있게 투표할 충분한 지식이 없을 수 있습니다.";
+s.topic_bad_02 = "출시 차단 요소는 무엇인가요?";
+s.topic_bad_02_reason = "";
+
+module.exports = s;
+
+
diff --git a/client-report/src/components/app.jsx b/client-report/src/components/app.jsx
index eb7bb49601..70bfe475d7 100644
--- a/client-report/src/components/app.jsx
+++ b/client-report/src/components/app.jsx
@@ -32,6 +32,16 @@ function assertExists(obj, key) {
}
}
+const computeVoteTotal = (users) => {
+ let voteTotal = 0;
+
+ for (const count in users) {
+ voteTotal += users[count];
+ }
+
+ return voteTotal;
+};
+
const App = (props) => {
const [loading, setLoading] = useState(true);
const [consensus, setConsensus] = useState(null);
@@ -44,6 +54,9 @@ const App = (props) => {
const [isNarrativeReport, setIsNarrativeReport] = useState(
window.location.pathname.split("/")[1] === "narrativeReport"
);
+ const [isStatsOnly, setIsStatsOnly] = useState(
+ window.location.pathname.split("/")[1] === "stats"
+ );
const [dimensions, setDimensions] = useState({
width: window.innerWidth,
height: window.innerHeight,
@@ -586,7 +599,26 @@ const App = (props) => {
);
}
- return (
+ return isStatsOnly ? (
+
+
+
Participants
+
{ptptCountTotal}
+
+
+
Comments
+
{math["n-cmts"]}
+
+
+
Votes
+
{computeVoteTotal(math["user-vote-counts"])}
+
+
+
Opinion Groups
+
{math["group-clusters"].length}
+
+
+ ) : (
` | An object mapping group IDs (as strings) to arrays of "representative" comments for that group. See [Repness Object](#repness-object-repnessgroupid) for `RepnessItem` structure. | (object) |
+| `consensus` | `object` | Contains arrays of comment IDs that represent points of consensus. See [Consensus Object](#consensus-object) for details. | (object) |
+| `meta-tids` | `number[]` | Array of comment IDs marked as "meta" comments. | `[]` |
+| `votes-base` | `Record
` | An object mapping comment IDs (as strings) to vote distributions across base clusters. See [Votes Base Object](#votes-base-object-votes-basecommentid) for `VotesBaseItem` structure. | (object) |
+| `group-votes` | `Record` | An object mapping group IDs (as strings) to aggregated vote data for that group. See [Group Votes Object](#group-votes-object-group-votesgroupid) for `GroupVotesItem` structure. | (object) |
+| `base-clusters` | `BaseClustersObject` | An object describing the base clusters derived from participant projections. See [Base Clusters Object](#base-clusters-object) for details. | (object) |
+| `group-clusters` | `GroupClusterItem[]` | An array of objects, each describing a group cluster. These groups are formed by clustering the `base-clusters`. See [Group Cluster Item](#group-cluster-item-within-group-clusters-array) for details. | (array of objects) |
+| `user-vote-counts` | `Record` | An object mapping participant IDs (pids, as strings) to the number of votes they have cast. | (object) |
+| `lastModTimestamp` | `number` \| `null` | Timestamp of the last moderation action. | `null` |
+| `lastVoteTimestamp` | `number` | Timestamp of the most recent vote included in this calculation. | `1740047365775` |
+| `comment-priorities` | `Record` | An object mapping comment IDs (as strings) to their calculated priority scores, used for determining which comments to show next. | (object) |
+| `group-aware-consensus` | `Record` | An object mapping comment IDs (as strings) to a "group-aware consensus" score. | (object) |
+| `math_tick` | `number` | A version integer indicating when this math data was generated. Used for caching and determining if data is stale. | 1 |
+| `subgroup-clusters` | `Record` | (Potentially ephemeral, processed by `processMathObject`) Describes clusters within each main group. | (object) |
+| `subgroup-votes` | `Record` | (Potentially ephemeral, processed by `processMathObject`) Vote data for subgroups. | (object) |
+| `subgroup-repness` | `Record` | (Potentially ephemeral, processed by `processMathObject`) Repness data for subgroups. | (object) |
+
+*Note: Fields like `subgroup-clusters`, `subgroup-votes`, and `subgroup-repness` are handled by `processMathObject` in `pca.ts`. Their structure mirrors their top-level counterparts but nested under parent group IDs. They are deleted after processing in `processMathObject`.*
+
+## PCA Object
+
+The `pca` field contains the direct results of the Principal Component Analysis.
+
+| Field Name | Type | Description | Example |
+|----------------------|--------------|-------------------------------------------------------------------------------------------------------------|-----------------------------------|
+| `comps` | `number[][]` | Principal components. An array of arrays, where each inner array represents a component vector. `comps[0]` is PC1, `comps[1]` is PC2, etc. Each value in an inner array corresponds to a participant's loading on that component for a specific comment (derived from `tids` order). `[dimensions][participants]` in `PcaCacheItem`. | (array of arrays) |
+| `center` | `number[]` | The mean vector of the original data, used for centering before PCA. Each value corresponds to a comment. | `[-0.082..., 0.063..., ...]` |
+| `comment-extremity` | `number[]` | A measure of how "extreme" or differentiating each comment is. Higher values mean the comment is more differentiating. Each value corresponds to a comment in `tids` order. | `[1.712..., 1.304..., ...]` |
+| `comment-projection` | `number[][]` | Projections of each comment onto the principal components. `comment-projection[0]` is the projection on PC1, `comment-projection[1]` on PC2. Each inner array has values corresponding to each comment. | (array of arrays) |
+
+## Repness Object (`repness[groupId]`)
+
+Each item in the `repness[groupId]` array describes a comment that is representative of that group.
+
+| Field Name | Type | Description |
+|------------------|----------|------------------------------------------------------------------------------------------------------------|
+| `tid` | `number` | The ID of the representative comment. |
+| `p-test` | `number` | Statistical test result related to the comment's representativeness. |
+| `repness` | `number` | A score indicating how representative the comment is for the group. |
+| `n-trials` | `number` | Number of trials/participants involved in this repness calculation for the comment within the group. |
+| `n-success` | `number` | Number of "successes" (e.g., group members agreeing with a comment the group generally agrees with). |
+| `p-success` | `number` | Proportion of successes (`n-success / n-trials`). |
+| `repful-for` | `string` | Indicates if the comment is representative for "agree" or "disagree" sentiments within the group. |
+| `repness-test` | `number` | Another statistical test value for repness. |
+| `n-agree` | `number` | (Optional) Number of agreements for this comment within the group. |
+| `best-agree` | `boolean`| (Optional) True if this is the "best agree" comment for the group based on some criteria. |
+
+## Consensus Object
+
+Describes comments that have broad agreement or disagreement across the conversation.
+
+| Field Name | Type | Description |
+|------------|---------|-----------------------------------------------------------------------------|
+| `agree` | `ConsensusItem[]` | An array of items representing comments with general agreement. See [Consensus Item Structure](#consensus-item-structure) below. |
+| `disagree` | `ConsensusItem[]` | An array of items representing comments with general disagreement. See [Consensus Item Structure](#consensus-item-structure) below. |
+
+### Consensus Item Structure
+
+Each object within the `agree` and `disagree` arrays has the following structure, derived from the `select-consensus-comments` and `format-stat` helper functions in `math/src/polismath/math/repness.clj`:
+
+| Field Name | Type | Description |
+|---------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `tid` | `number` | The ID of the comment. |
+| `n-success` | `number` | For `agree` items: the count of agree votes (`:na`). For `disagree` items: the count of disagree votes (`:nd`) for that comment across all participants. |
+| `n-trials` | `number` | The total number of participants who saw and explicitly voted agree/disagree on the comment (derived from `:ns` in `comment-stats`, which counts non-null votes). |
+| `p-success` | `number` | The probability of "success" (agreeing or disagreeing). Calculated with a prior: `(+ 1 n-success) / (+ 2 n-trials)`. Corresponds to `:pa` or `:pd` from `comment-stats`. |
+| `p-test` | `number` | A statistical test value (likely a z-score from `stats/prop-test`) for `p-success`. Corresponds to `:pat` or `:pdt` from `comment-stats`. |
+
+## Votes Base Object (`votes-base[commentId]`)
+
+For each comment ID, this object stores how participants in different base clusters voted.
+
+| Field Name | Type | Description |
+|------------|------------|----------------------------------------------------------------------------------------------------------------------------------------------|
+| `A` | `number[]` | Array where each index corresponds to a base cluster. The value is the count of "Agree" votes from participants in that base cluster for this comment. |
+| `D` | `number[]` | Array for "Disagree" vote counts per base cluster. |
+| `S` | `number[]` | Array for the sum of all votes (Agree, Disagree, Pass) per base cluster for this comment. |
+
+## Group Votes Object (`group-votes[groupId]`)
+
+For each group ID, this object stores aggregated vote counts on comments.
+
+| Field Name | Type | Description |
+|---------------|-----------------------------|-----------------------------------------------------------------------------------------------------------|
+| `votes` | `Record` | An object mapping comment IDs (as strings) to their vote counts within this specific group. |
+| `n-members` | `number` | The total number of participants belonging to this group. |
+
+### VoteCounts (within `group-votes[groupId].votes[commentId]`)
+
+| Field Name | Type | Description |
+|------------|----------|-------------------------------------------------------|
+| `A` | `number` | Number of "Agree" votes for the comment in this group. |
+| `D` | `number` | Number of "Disagree" votes for the comment in this group. |
+| `S` | `number` | Sum of all votes (Agree, Disagree, Pass) for the comment in this group. |
+
+## Base Clusters Object
+
+This object describes the initial, finer-grained clusters of participants. These are then clustered again to form the `group-clusters`.
+
+| Field Name | Type | Description |
+|------------|--------------|-------------------------------------------------------------------------------------------------------------------|
+| `x` | `number[]` | Array of x-coordinates for each base cluster in the 2D PCA projection. Index corresponds to `id` and `members`. |
+| `y` | `number[]` | Array of y-coordinates for each base cluster. |
+| `id` | `number[]` | Array of unique IDs for each base cluster. |
+| `count` | `number[]` | Array indicating the number of participants in each base cluster. |
+| `members` | `number[][]` | Array of arrays. Each inner array contains the participant IDs (pids) belonging to the corresponding base cluster. |
+
+## Group Cluster Item (within `group-clusters` array)
+
+Each item in this array describes a higher-level group.
+
+| Field Name | Type | Description |
+|------------|------------|---------------------------------------------------------------------------------------------------------------|
+| `id` | `number` | The unique ID of this group cluster. |
+| `center` | `number[]` | The [x, y] coordinates of the centroid of this group cluster in the 2D PCA projection. |
+| `members` | `number[]` | An array of base cluster IDs that belong to this group cluster. These are IDs from `base-clusters.id`. |
+| `n-members`| `number` | (Added during processing in `getClusters` in `polis.js`) The total number of participants in this group cluster. This is derived from `group-votes[id]["n-members"]`. |
+
+## Processing and Transformations
+
+It's important to note that the raw data from `math_main` undergoes some processing in `server/src/utils/pca.ts` within the `processMathObject` function before being cached and served to clients. This function:
+
+- Normalizes `group-clusters`, `repness`, `group-votes`, `subgroup-repness`, `subgroup-votes`, and `subgroup-clusters` to be arrays of objects with `id` and `val` properties if they are not already arrays.
+- Specifically maps `group-clusters` to have an `id` and `val` structure.
+- Converts object-based subgroup properties (like `repness`, `group-votes` if they were objects) into arrays of `{id: number, val: object}`.
+- Then, it "un-normalizes" `repness` and `group-votes` back into objects keyed by `id`, and `group-clusters` back into an array of its original `val` objects (where `val` itself contains an `id`).
+- Deletes `subgroup-repness`, `subgroup-votes`, and `subgroup-clusters` after their information has potentially been merged or processed.
+
+The client-side code in `client-participation/js/stores/polis.js` further processes this data, for example, by:
+
+- Calculating `myBid` (the bucket ID for the current user).
+- Potentially creating "bigBuckets" which are summary buckets for groups.
+- Adding participants of interest (PoIs) to the visualization data.
+- Projecting the user's own votes (`projectSelf`).
+
+Understanding these transformations is key to interpreting the data correctly both in its stored/cached form and how it's used by the frontend.
diff --git a/example.env b/example.env
index 1a265c7ea8..a1ca4d17d4 100644
--- a/example.env
+++ b/example.env
@@ -29,7 +29,7 @@ POSTGRES_HOST=postgres:${POSTGRES_PORT}
POSTGRES_USER=postgres
POSTGRES_PASSWORD=oiPorg3Nrz0yqDLE
# Always required. Replace with your own database URL if not using dockerized postgres.
-DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
+DATABASE_URL=postgres://postgres:oiPorg3Nrz0yqDLE@postgres:5432/polis-test
# Makefile will read this to determine if the database is running in a docker container.
POSTGRES_DOCKER=true
diff --git a/server/.dockerignore b/server/.dockerignore
index 5f7f752700..b0e5dc0d13 100644
--- a/server/.dockerignore
+++ b/server/.dockerignore
@@ -1,6 +1,9 @@
.env
.git
.google_creds_temp
+.venv/
+coverage/
+dist
logs/
node_modules/
-npm-debug.log*
+npm-debug.log*
\ No newline at end of file
diff --git a/server/.eslintrc.js b/server/.eslintrc.js
index 4f2ad226ad..4c1b40d914 100644
--- a/server/.eslintrc.js
+++ b/server/.eslintrc.js
@@ -7,9 +7,10 @@ module.exports = {
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
overrides: [
{
- files: ["bin/*.js"],
+ files: ["bin/*.js", "__tests__/**/*.ts"],
rules: {
"no-console": "off",
+ "no-restricted-properties": "off"
}
}
],
@@ -46,5 +47,5 @@ module.exports = {
"prefer-rest-params": 1,
"prefer-spread": 1
},
- ignorePatterns: ["dist"]
+ ignorePatterns: ["coverage", "dist"]
};
diff --git a/server/.gitignore b/server/.gitignore
index 44e09091f3..48fb2152ae 100644
--- a/server/.gitignore
+++ b/server/.gitignore
@@ -1,7 +1,8 @@
.env
.google_creds_temp
+.venv/
+coverage/
dist
logs/
node_modules/
-npm-debug.log*
-.venv/
\ No newline at end of file
+npm-debug.log*
\ No newline at end of file
diff --git a/server/.nvmrc b/server/.nvmrc
deleted file mode 100644
index 209e3ef4b6..0000000000
--- a/server/.nvmrc
+++ /dev/null
@@ -1 +0,0 @@
-20
diff --git a/server/__tests__/README.md b/server/__tests__/README.md
new file mode 100644
index 0000000000..1c6b2d7545
--- /dev/null
+++ b/server/__tests__/README.md
@@ -0,0 +1,129 @@
+# Testing Guide
+
+This directory contains the test suite for the Polis server. The tests are organized by type (unit, integration, e2e) and use Jest as the test runner.
+
+## Getting Started
+
+To run the tests, you'll need:
+
+- A local PostgreSQL database for testing
+- Node.js and npm installed
+
+## Running Tests
+
+### All Tests
+
+```bash
+npm test
+```
+
+### Unit Tests Only
+
+```bash
+npm run test:unit
+```
+
+### Integration Tests Only
+
+```bash
+npm run test:integration
+```
+
+### Feature Tests Only
+
+```bash
+npm run test:feature
+```
+
+### Run Specific Tests
+
+```bash
+# Run tests in a specific file
+npm test -- __tests__/integration/participation.test.js
+
+# Run tests that match a specific name
+npm test -- -t "should do something specific"
+```
+
+## Database Setup for Tests
+
+The tests require a clean database state to run successfully. There are several ways to manage this:
+
+### Option 1: Reset Database Before Running Tests
+
+This will completely reset your database, dropping and recreating it with a fresh schema:
+
+```bash
+# Reset the database immediately
+npm run db:reset
+
+# Run tests with a database reset first
+RESET_DB_BEFORE_TESTS=true npm test
+```
+
+⚠️ **WARNING**: The `db:reset` script will delete ALL data in the database specified by `DATABASE_URL`.
+
+## Mailer Testing
+
+A maildev container is typically running (see `docker-compose.dev.yml`) and will capture emails sent during testing. You can view the emails at `http://localhost:1080` (SMTP port 1025).
+
+The test suite includes helper functions in `__tests__/setup/email-helpers.js` to interact with MailDev:
+
+```javascript
+// Find an email sent to a specific recipient
+const email = await findEmailByRecipient('test@example.com');
+
+// Get all emails currently in MailDev
+const allEmails = await getEmails();
+
+// Clean up emails before/after tests
+await deleteAllEmails();
+
+// Extract password reset URLs from emails
+const { url, token } = getPasswordResetUrl(email);
+```
+
+## Response Format Handling
+
+The test suite includes robust handling for the various response formats from the API:
+
+- **JSON Responses**: Automatically parsed into JavaScript objects
+- **Text Responses**: Preserved as strings
+- **Gzipped Content**: Automatically detected and decompressed, even when incorrectly marked
+- **Mixed Content-Types**: Handles cases where JSON content is served with non-JSON content types
+
+## Test Safety Features
+
+The test environment includes this safety feature:
+
+- **Production Database Prevention**: Tests will not run against production databases (URLs containing 'amazonaws', 'prod', etc.)
+
+## Troubleshooting Common Issues
+
+### Participant Creation Issues
+
+If tests fail with duplicate participant errors, try:
+
+```bash
+npm run db:reset
+```
+
+### Database Connection Errors
+
+Check that:
+
+1. Your PostgreSQL server is running
+2. Your DATABASE_URL environment variable is correct
+3. Database and schema exist (you can use `npm run db:reset` to create them)
+
+### Test Timeouts
+
+If tests timeout, try:
+
+1. Increase the timeout in individual tests:
+
+ ```javascript
+ jest.setTimeout(90000); // Set timeout to 90 seconds
+ ```
+
+2. Check for any blocking async operations that might not be resolving
diff --git a/server/__tests__/app-loader.ts b/server/__tests__/app-loader.ts
new file mode 100644
index 0000000000..9f56dad993
--- /dev/null
+++ b/server/__tests__/app-loader.ts
@@ -0,0 +1,54 @@
+/* eslint-disable no-console */
+/**
+ * This module provides controlled loading of the main Express app
+ * to avoid issues with the Jest environment and async loading.
+ *
+ * Instead of directly importing app.ts, tests should use this loader
+ * which manages the initialization timing more carefully.
+ */
+
+import { Express } from 'express';
+
+// Cache the app instance to avoid multiple initializations
+let appInstance: Express | null = null;
+let appInitPromise: Promise | null = null;
+let isAppReady = false;
+
+/**
+ * Asynchronously get the Express app instance, waiting for proper initialization
+ * @returns Promise resolving to Express app when ready
+ */
+async function getApp(): Promise {
+ if (isAppReady && appInstance) {
+ return appInstance;
+ }
+
+ if (!appInitPromise) {
+ // Create the initialization promise only once
+ // Promise executor should not be async
+ appInitPromise = new Promise((resolve, reject) => {
+ try {
+ // Load the app
+ const app = require('../app').default as Express;
+ appInstance = app;
+
+ // Wait for any asynchronous initialization to complete
+ // Express itself doesn't have built-in ready events, but we can use
+ // helpers initialization promise that's available in our app
+ // Use a minimal delay to ensure any internal initialization is complete
+ setTimeout(() => {
+ isAppReady = true;
+ resolve(app);
+ }, 100);
+
+ } catch (err) {
+ console.error('AppLoader: Error loading app:', err);
+ reject(err);
+ }
+ });
+ }
+
+ return appInitPromise;
+}
+
+export { getApp };
\ No newline at end of file
diff --git a/server/__tests__/feature/comment-repetition.test.ts b/server/__tests__/feature/comment-repetition.test.ts
new file mode 100644
index 0000000000..ee824dcae7
--- /dev/null
+++ b/server/__tests__/feature/comment-repetition.test.ts
@@ -0,0 +1,162 @@
+/**
+ * Special test for detecting comment repetition bug
+ *
+ * This test creates a conversation with many comments, then has a participant
+ * vote on comments until there are none remaining. It checks that:
+ * 1. Each comment is seen exactly once
+ * 2. No comments are repeated for a participant who has already voted on them
+ */
+
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import {
+ initializeParticipant,
+ setupAuthAndConvo,
+ submitVote
+} from '../setup/api-test-helpers';
+import type { VoteResponse } from '../../types/test-helpers';
+
+interface CommentRepetition {
+ commentId: number;
+ count: number;
+}
+
+interface NextComment {
+ tid: number;
+ [key: string]: any;
+}
+
+// Constants
+const NUM_COMMENTS = 10; // Total number of comments to create
+
+describe('Comment Repetition Bug Test', () => {
+ // Test state
+ let conversationId: string;
+ const allCommentIds: number[] = [];
+
+ // Setup: Register admin, create conversation, and create comments
+ beforeAll(async () => {
+ try {
+ const setup = await setupAuthAndConvo({
+ commentCount: NUM_COMMENTS,
+ conversationOptions: {
+ topic: `Comment Repetition Test ${Date.now()}`,
+ description: 'A conversation to test for the comment repetition bug'
+ }
+ });
+
+ conversationId = setup.conversationId;
+
+ // Add the created comments to our tracking array
+ allCommentIds.push(...setup.commentIds);
+
+ console.log(`Created ${NUM_COMMENTS} total comments for the test conversation`);
+ } catch (error) {
+ console.error('Setup failed:', error);
+ throw error;
+ }
+ });
+
+ test('A participant should never see the same comment twice', async () => {
+ // Track seen comments to detect repetitions
+ const seenCommentIds = new Set();
+ const commentRepetitions = new Map(); // Track how many times each comment is seen
+ let votedCount = 0;
+ // Add an array to track the order of comments seen
+ const orderedCommentIds: number[] = [];
+
+ // STEP 1: Initialize anonymous participant
+ const { agent: participantAgent, body: initBody } = await initializeParticipant(conversationId);
+
+ let nextComment = initBody.nextComment as NextComment;
+ let commentId = nextComment.tid;
+ let currentPid: string | undefined;
+
+ // STEP 2: Process each comment one by one
+ const MAX_ALLOWED_COMMENTS = NUM_COMMENTS + 1; // Allow one extra to detect repetition
+ let processedComments = 0;
+
+ while (commentId) {
+ processedComments++;
+ if (processedComments > MAX_ALLOWED_COMMENTS) {
+ // Instead of throwing an error, use expect to fail the test properly
+ expect(processedComments).toBeLessThanOrEqual(
+ MAX_ALLOWED_COMMENTS,
+ `Processed ${processedComments} comments which exceeds maximum allowed (${MAX_ALLOWED_COMMENTS}). This indicates a comment repetition issue.`
+ );
+ break;
+ }
+
+ // Add the comment ID to our ordered list
+ orderedCommentIds.push(commentId);
+
+ // Check if we've seen this comment before
+ if (seenCommentIds.has(commentId)) {
+ // Update repetition count
+ commentRepetitions.set(commentId, (commentRepetitions.get(commentId) || 1) + 1);
+ console.warn(`REPETITION DETECTED: Comment ${commentId} seen again`);
+ } else {
+ seenCommentIds.add(commentId);
+ commentRepetitions.set(commentId, 1);
+ votedCount++;
+ }
+
+ // Vote on the current comment (randomly agree, disagree, or pass)
+ const voteOptions = [-1, 1, 0]; // -1 agree, 1 disagree, 0 pass
+ const randomVote = voteOptions[Math.floor(Math.random() * voteOptions.length)] as -1 | 0 | 1;
+
+ // Build vote payload
+ const voteData = {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: randomVote,
+ pid: currentPid
+ };
+
+ // Submit vote using our improved helper
+ const voteResponse: VoteResponse = await submitVote(participantAgent, voteData);
+
+ // Check for error in response
+ expect(voteResponse.status).toBe(200, 'Failed to submit vote');
+
+ // Update the participant ID from the vote response for the next vote
+ currentPid = voteResponse.body.currentPid;
+
+ // Update nextComment with the vote response
+ nextComment = voteResponse.body.nextComment as NextComment;
+ commentId = nextComment?.tid;
+
+ // Log progress periodically
+ if ((votedCount + 1) % 5 === 0) {
+ console.log(`Voted on ${votedCount} unique comments out of ${NUM_COMMENTS} total.`);
+ }
+ }
+
+ // STEP 3: Analyze results
+ console.log('\nFINAL RESULTS:');
+ console.log(`Seen ${seenCommentIds.size} unique comments out of ${NUM_COMMENTS} total`);
+ console.log(`Voted on ${votedCount} comments`);
+
+ // Print the ordered sequence of comments
+ console.log('\nORDERED COMMENT SEQUENCE:');
+ console.log(orderedCommentIds);
+ console.log(`Total comments in sequence: ${orderedCommentIds.length}`);
+
+ // Check for repeats
+ const repeatedComments: CommentRepetition[] = Array.from(commentRepetitions.entries())
+ .filter(([_, count]) => count > 1)
+ .map(([commentId, count]) => ({ commentId, count }));
+
+ if (repeatedComments.length > 0) {
+ console.warn('Found repeated comments:', repeatedComments);
+ }
+
+ // Check if all comments were seen
+ const unseenComments = allCommentIds.filter((id) => !seenCommentIds.has(id));
+ if (unseenComments.length > 0) {
+ console.log(`Comments never seen: ${unseenComments.length} of ${NUM_COMMENTS}`);
+ }
+
+ // Test assertions
+ expect(repeatedComments.length).toBe(0, `Found ${repeatedComments.length} repeated comments`); // No comment should be repeated
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/feature/concurrent-participant-creation.test.ts b/server/__tests__/feature/concurrent-participant-creation.test.ts
new file mode 100644
index 0000000000..85c852b593
--- /dev/null
+++ b/server/__tests__/feature/concurrent-participant-creation.test.ts
@@ -0,0 +1,115 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import {
+ authenticateAgent,
+ initializeParticipant,
+ newTextAgent,
+ setupAuthAndConvo,
+ wait,
+} from '../setup/api-test-helpers';
+import type { Agent, Response } from 'supertest';
+
+const NUM_CONCURRENT_VOTES = 20;
+
+describe('Concurrent Participant Creation Test', () => {
+ let conversationId: string;
+ let commentId: number;
+
+ beforeAll(async () => {
+ const setup = await setupAuthAndConvo({ commentCount: 1 });
+ conversationId = setup.conversationId;
+ commentId = setup.commentIds[0];
+ });
+
+ test('should handle concurrent anonymous participant creation via voting without crashing', async () => {
+ const participantVotePromises: Promise[] = [];
+
+ const participantAgents: Agent[] = [];
+ for (let i = 0; i < NUM_CONCURRENT_VOTES; i++) {
+ // Initialize anonymous participant and get their unique agent
+ const { cookies } = await initializeParticipant(conversationId);
+ const participantAgent = await newTextAgent();
+ authenticateAgent(participantAgent, cookies);
+ participantAgents.push(participantAgent);
+ }
+
+ participantAgents.forEach((agent, index) => { // Iterate over the anonymous agents
+ const votePayload = {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: ((index % 3) - 1) as -1 | 0 | 1,
+ pid: 'mypid',
+ agid: 1,
+ lang: 'en'
+ };
+
+ // Use the specific anonymous participant agent directly
+ const votePromise = agent.post('/api/v3/votes').send(votePayload);
+ participantVotePromises.push(votePromise);
+ });
+
+ let results: Response[] = [];
+ try {
+ results = await Promise.all(participantVotePromises);
+ console.log('All vote promises settled.');
+ } catch (error) {
+ console.error('Error during Promise.all(votePromises):', error);
+ }
+
+ await wait(1000);
+
+ console.log('\n--- Concurrent Vote Results ---');
+ let successCount = 0;
+ let duplicateVoteErrors = 0; // Should be 0
+ let internalServerErrorsFromVote = 0; // Expecting N > 0 (for participants_zid_pid_key)
+ let otherErrors = 0;
+ const pidsAssigned: (string | undefined)[] = [];
+
+ results.forEach((response, index) => {
+ let currentPidFromBody: string | undefined;
+ if (response.status === 200 && response.headers['content-type']?.includes('application/json')) {
+ try {
+ const parsedBody = JSON.parse(response.text);
+ currentPidFromBody = parsedBody?.currentPid;
+ } catch (e) {
+ console.warn(`Participant ${index + 1}: Failed to parse JSON body for 200 response. Text: ${response.text}`);
+ }
+ }
+ pidsAssigned.push(currentPidFromBody);
+
+ if (response.status === 200) {
+ successCount++;
+ } else if (response.status === 406 && response.text?.includes('polis_err_vote_duplicate')) {
+ duplicateVoteErrors++;
+ console.warn(`Participant ${index + 1} vote resulted in 406 (polis_err_vote_duplicate): Text: ${response.text}`);
+ } else if (response.status === 500 && response.text?.includes('polis_err_vote')) {
+ internalServerErrorsFromVote++;
+ console.error(`Participant ${index + 1} vote resulted in 500 (polis_err_vote): Text: ${response.text}`);
+ } else {
+ otherErrors++;
+ console.error(`Participant ${index + 1} vote failed with status ${response.status}: Text: ${response.text}`);
+ }
+ });
+
+ console.log(`Successful votes: ${successCount}/${NUM_CONCURRENT_VOTES}`);
+ console.log(`Duplicate vote errors (406): ${duplicateVoteErrors}/${NUM_CONCURRENT_VOTES}`);
+ console.log(`Internal server errors from vote (500 polis_err_vote): ${internalServerErrorsFromVote}/${NUM_CONCURRENT_VOTES}`);
+ console.log(`Other errors: ${otherErrors}/${NUM_CONCURRENT_VOTES}`);
+ console.log('PIDs assigned/returned (only from 200 responses):', pidsAssigned.filter(pid => pid !== undefined));
+
+ expect(true).toBe(true); // Server did not crash
+
+ expect(successCount + duplicateVoteErrors + internalServerErrorsFromVote + otherErrors).toBe(NUM_CONCURRENT_VOTES);
+ expect(otherErrors).toBe(0); // Expect only 200s, 406s, or our specific 500s
+
+ const successfulPids = pidsAssigned.filter(pid => pid !== undefined && pid !== 'mypid') as string[];
+ const uniquePids = new Set(successfulPids);
+ if (internalServerErrorsFromVote > 0 || duplicateVoteErrors > 0) {
+ console.warn(`WARNING: ${internalServerErrorsFromVote} internal server errors (500) and ${duplicateVoteErrors} duplicate vote errors (406) occurred.`);
+ }
+ if (successfulPids.length > 0) {
+ console.log(`Total successful PIDs assigned: ${successfulPids.length}, Unique PIDs: ${uniquePids.size}`);
+ expect(successfulPids.length).toBe(uniquePids.size);
+ }
+
+ }, 30000);
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/CHECKLIST.md b/server/__tests__/integration/CHECKLIST.md
new file mode 100644
index 0000000000..c42fafa365
--- /dev/null
+++ b/server/__tests__/integration/CHECKLIST.md
@@ -0,0 +1,273 @@
+# Checklist for Integration Tests
+
+This checklist tracks API endpoints and functional domains that should be tested in the integration test suite. This ensures comprehensive coverage of the API and helps identify gaps in testing.
+
+## Legend
+
+- ✅ Fully tested
+- 🔶 Partially tested
+- ❌ Not tested yet
+- ⛔️ Expected to fail, or has known issues
+- 🙈 Out of scope
+
+## Authentication
+
+### Auth Endpoints
+
+- ✅ POST /auth/new - User registration
+- ✅ POST /auth/login - User login
+- ✅ POST /auth/deregister - User logout
+- ✅ POST /auth/pwresettoken - Password reset token
+- ✅ GET /auth/pwreset - Password reset page
+- ✅ POST /auth/password - Process password reset
+
+### Auth Features
+
+- ✅ Anonymous participation
+- ✅ Authenticated participation
+- ✅ Token-based authentication
+- ✅ Cookie-based authentication
+- ✅ XID-based authentication
+- ✅ Password reset flow
+
+## Conversations
+
+### Conversation Management
+
+- ✅ POST /conversations - Create conversation
+- ✅ GET /conversations - List conversations
+- ✅ GET /conversation/:conversation_id - Get conversation details
+- ✅ PUT /conversations - Update conversation
+- ⛔️ POST /conversation/close - Close conversation
+- ⛔️ POST /conversation/reopen - Reopen conversation
+- 🔶 POST /reserve_conversation_id - Reserve conversation ID
+
+### Conversation Features
+
+- ✅ Public vs. private conversations
+- ⛔️ Conversation closure
+- ✅ Conversation sharing settings
+- 🙈 Conversation monitoring
+- 🙈 Conversation embedding
+- ✅ Conversation statistics
+- ✅ Conversation preload information
+- 🔶 Recent conversation activity
+
+## Comments
+
+### Comment Endpoints
+
+- ✅ POST /comments - Create comment
+- ✅ GET /comments - List comments
+- 🙈 GET /comments/translations - Get comment translations
+- ✅ PUT /comments - Update comment
+
+### Comment Features
+
+- ✅ Comment creation
+- ✅ Comment retrieval with filters
+- ✅ Comment moderation
+- 🔶 Comment flagging
+- 🙈 Comment translation
+
+## Participation
+
+### Participation Endpoints
+
+- ✅ GET /participationInit - Initialize participation
+- ✅ GET /participation - Get participation data
+- ✅ GET /nextComment - Get next comment for voting
+- ✅ POST /participants - Participant metadata
+- ✅ PUT /participants_extended - Update participant settings
+
+### Participation Features
+
+- ✅ Anonymous participation
+- ✅ Authenticated participation
+- ✅ XID-based participation
+- ✅ Participation with custom metadata
+- 🔶 POST /query_participants_by_metadata - Query participants by metadata
+
+## Voting
+
+### Vote Endpoints
+
+- ✅ POST /votes - Submit vote
+- ✅ GET /votes - Get votes
+- ✅ GET /votes/me - Get my votes
+- 🔶 GET /votes/famous - Get famous votes
+- 🔶 POST /stars - Star comments
+- 🔶 POST /upvotes - Upvote comments
+
+### Vote Features
+
+- ✅ Anonymous voting
+- ✅ Authenticated participation
+- ✅ Vote retrieval
+- ✅ Vote updating
+
+## Math and Analysis
+
+### Math Endpoints
+
+- ✅ GET /math/pca2 - Principal Component Analysis
+- ✅ GET /math/correlationMatrix - Get correlation matrix
+- 🙈 POST /math/update - Trigger math recalculation
+- 🔶 GET /bid - Get bid mapping
+- 🔶 GET /bidToPid - Get bid to pid mapping
+- 🔶 GET /xids - Get XID information
+
+### Report Endpoints
+
+- 🔶 GET /reports - Get reports
+- 🔶 POST /reports - Create report
+- 🔶 PUT /reports - Update report
+- 🙈 GET /reportNarrative - Get report narrative
+- ⛔️ GET /snapshot - Get conversation snapshot
+
+## Data Export
+
+### Export Endpoints
+
+- 🔶 GET /dataExport - Export conversation data
+- 🔶 GET /dataExport/results - Get export results
+- 🔶 GET /reportExport/:report_id/:report_type - Export report
+- ❌ GET /xid/:xid_report - Get XID report
+
+## System and Utilities
+
+### Health Endpoints
+
+- ✅ GET /testConnection - Test connectivity
+- ✅ GET /testDatabase - Test database connection
+
+### Context and Metadata
+
+- ✅ GET /contexts - Get available contexts
+- ✅ POST /contexts - Create context
+- ✅ GET /domainWhitelist - Get whitelisted domains
+- ✅ POST /domainWhitelist - Update whitelisted domains
+- 🔶 POST /xidWhitelist - Update XID whitelist
+
+### Metadata Management
+
+- ✅ GET /metadata/questions - Get metadata questions
+- ✅ POST /metadata/questions - Create metadata question
+- ✅ DELETE /metadata/questions/:pmqid - Delete metadata question
+- ✅ GET /metadata/answers - Get metadata answers
+- ✅ POST /metadata/answers - Create metadata answer
+- ✅ DELETE /metadata/answers/:pmaid - Delete metadata answer
+- 🔶 GET /metadata - Get all metadata
+- 🔶 GET /metadata/choices - Get metadata choices
+
+### Miscellaneous
+
+- ✅ POST /tutorial - Track tutorial steps
+- ✅ POST /einvites - Send email invites
+- ✅ GET /einvites - Get email invites
+- ✅ GET /verify - Email invite verification
+- ❌ GET /tryCookie - Test cookie functionality
+- 🙈 GET /perfStats_9182738127 - Performance statistics
+- 🙈 GET /dummyButton - Test dummy button
+- ✅ GET /conversationPreloadInfo - Get conversation preload info
+- ✅ GET /conversationStats - Get conversation statistics
+- ❌ GET /conversationUuid - Get conversation UUID
+- 🔶 GET /conversationsRecentActivity - Get recent activity
+- 🔶 GET /conversationsRecentlyStarted - Get recently started conversations
+
+## Extended Features
+
+### User Management
+
+- ✅ GET /users - List users (admin)
+- ✅ PUT /users - Update user (admin)
+- ✅ POST /users/invite - Invite users (admin)
+- 🔶 POST /joinWithInvite - Join with invite
+
+### Social Features
+
+- 🔶 GET /ptptois - Get participant ois
+- 🔶 PUT /ptptois - Update participant ois
+- 🙈 GET /locations - Get locations
+
+### Notifications
+
+- ✅ GET /notifications/subscribe - Subscribe to notifications
+- ✅ GET /notifications/unsubscribe - Unsubscribe from notifications
+- ✅ POST /convSubscriptions - Subscribe to conversation updates
+- ✅ POST /sendCreatedLinkToEmail - Send created link to email
+- 🔶 POST /sendEmailExportReady - Send email export ready notification
+- ❌ POST /notifyTeam - Notify team
+
+## Reports and Exports
+
+- ✅ GET /api/v3/reports - Get reports
+- ✅ POST /api/v3/reports - Create report
+- ✅ PUT /api/v3/reports - Update report
+- ✅ GET /api/v3/reportExport/:report_id/:report_type - Export report data
+- ✅ GET /api/v3/dataExport - Initiate data export task
+- ❌ GET /api/v3/dataExport/results - Get export results (requires S3 setup)
+
+## Notes on Test Implementation
+
+1. **Legacy Quirks**: Tests should handle the known quirks of the legacy server, including:
+ - Plain text responses with content-type: application/json
+ - Error responses as text rather than structured JSON
+ - Falsy IDs (0 is a valid ID)
+
+2. **Handling Authentication**: Tests should verify all authentication methods:
+ - Token-based auth
+ - Cookie-based auth
+ - Combined auth strategies
+
+3. **Coverage Strategy**: Focus on:
+ - Core user flows first
+ - Edge cases and validation
+ - Error handling
+ - Authentication and authorization
+
+4. **Known Issues**: Be aware of potential stability issues with:
+ - `/conversation/close` endpoint (may hang)
+ - `/auth/deregister` endpoint (may timeout)
+ - `/comments/translations` endpoint (always returns 400 error)
+
+## Out-of-Scope Features
+
+Some features of the server are considered out-of-scope for integration testing due to being deprecated, unused, or requiring external integrations that would be difficult to test reliably:
+
+- **Embedded conversations**: The embedding functionality (`/embed`, `/embedPreprod`, `/embedReport`, etc.) is best tested in end-to-end testing rather than integration testing.
+- **Locations / geocode**: The location-based features (`/api/v3/locations`) would require third-party geocoding services.
+- **Social integrations**: Features related to social media integration are not prioritized for testing.
+- **Report narrative**: The `/api/v3/reportNarrative` endpoint requires complex setup and may be better suited for manual testing.
+- **Translations**: Comment translation features (`/api/v3/comments/translations`) depend on external translation services.
+- **Performance and monitoring**: Endpoints like `/perfStats_9182738127` are designed for production monitoring rather than regular API usage.
+
+Some of these features may be covered by manual testing or end-to-end tests instead of integration tests, or may be deprecated in future versions of the application.
+
+## Current Coverage
+
+Based on the latest coverage report:
+
+- Overall code coverage: ~40% statements, ~38% branches, ~41% functions
+- Key areas with good coverage:
+ - App.js: 93% statements
+ - Password-related functionality: 82% statements
+ - Conversation management: 65% statements
+ - Voting: 68% statements in routes
+- Areas needing improvement:
+ - Notification functionality: 0% coverage
+ - Report functionality: 0-4% coverage
+ - Export functionality: 1-22% coverage
+
+### Participant & User Metadata
+
+- ✅ GET /api/v3/metadata - Get all metadata for a conversation
+- ✅ GET /api/v3/metadata/questions - Get metadata questions for a conversation
+- ✅ POST /api/v3/metadata/questions - Create a metadata question
+- ✅ DELETE /api/v3/metadata/questions/:pmqid - Delete a metadata question
+- ✅ GET /api/v3/metadata/answers - Get metadata answers for a conversation
+- ✅ POST /api/v3/metadata/answers - Create a metadata answer
+- ✅ DELETE /api/v3/metadata/answers/:pmaid - Delete a metadata answer
+- ✅ GET /api/v3/metadata/choices - Get metadata choices for a conversation
+- ✅ POST /api/v3/query_participants_by_metadata - Query participants by metadata
+- ✅ PUT /api/v3/participants_extended - Update participant extended settings
diff --git a/server/__tests__/integration/README.md b/server/__tests__/integration/README.md
new file mode 100644
index 0000000000..06090a0c59
--- /dev/null
+++ b/server/__tests__/integration/README.md
@@ -0,0 +1,254 @@
+# Integration Tests
+
+This directory contains integration tests for the Polis API. These tests verify the correctness of API endpoints by making actual HTTP requests to the server and checking the responses.
+
+## Structure
+
+Each test file focuses on a specific aspect of the API:
+
+- `auth.test.js` - Authentication endpoints
+- `comment.test.js` - Comment creation and retrieval endpoints
+- `conversation.test.js` - Conversation creation and management endpoints
+- `health.test.js` - Health check endpoints
+- `participation.test.js` - Participation and initialization endpoints
+- `tutorial.test.js` - Tutorial step tracking endpoints
+- `vote.test.js` - Voting endpoints
+
+## Shared Test Helpers
+
+To maintain consistency and reduce duplication, all test files use shared helper functions from `__tests__/setup/api-test-helpers.js`. These include:
+
+### Data Generation Helpers
+
+- `generateTestUser()` - Creates random user data for registration
+- `generateRandomXid()` - Creates random external IDs for testing
+
+### Entity Creation Helpers
+
+- `createConversation()` - Creates a conversation with the specified options
+- `createComment()` - Creates a comment in a conversation
+- `registerAndLoginUser()` - Registers and logs in a user in one step
+
+### Participation and Voting Helpers
+
+- `initializeParticipant()` - Initializes an anonymous participant for voting
+- `initializeParticipantWithXid()` - Initializes a participant for voting with an external ID
+- `submitVote()` - Submits a vote on a comment
+- `getVotes()` - Retrieves votes for a conversation
+- `getMyVotes()` - Retrieves a participant's votes
+
+### Response Handling Utilities
+
+- `validateResponse()` - Validates API responses with proper status and property checks
+- `formatErrorMessage()` - Formats error messages consistently from API responses
+- `hasResponseProperty()` - Safely checks for properties in responses (handles falsy values correctly)
+- `getResponseProperty()` - Safely gets property values from responses (handles falsy values correctly)
+- `extractCookieValue()` - Extracts a cookie value from response headers
+
+### Test Setup Helpers
+
+- `setupAuthAndConvo()` - Sets up authentication, creates a conversation, and comments in one step
+- `wait()` - Pauses execution for a specified time
+
+## Response Handling
+
+The test helpers are designed to handle various quirks of the legacy server:
+
+- **Content-Type Mismatches**: The legacy server sometimes sends plain text responses with `content-type: application/json`. Our test helpers handle this by attempting JSON parsing first, then falling back to raw text.
+
+- **Error Response Format**: Error responses are often plain text error codes (e.g., `polis_err_param_missing_password`) rather than structured JSON objects. The test helpers check for both formats.
+
+- **Gzip Compression**: Some responses are gzipped, either with or without proper `content-encoding: gzip` headers. The helpers automatically detect and decompress gzipped content.
+
+- **Falsy ID Values**: Special care is taken to handle IDs that might be 0 (which is a valid value but falsy in JavaScript), preventing false negative checks.
+
+### Email Testing
+
+The `email-helpers.js` file provides utilities for testing email functionality:
+
+- **Finding Emails**: `findEmailByRecipient()` locates emails sent to specific recipients
+- **Email Cleanup**: `deleteAllEmails()` removes all emails before and after tests
+- **Content Extraction**: Functions to extract specific content like reset URLs from emails
+- **Polling Mechanism**: Retry and timeout functionality to allow for email delivery delays
+
+These helpers are used in tests that verify email-based functionality like:
+
+- User invitations
+- Password resets
+- Notifications
+
+To use the email testing capabilities, ensure MailDev is running (included in the docker-compose setup) and accessible at .
+
+## Global Test Agent Pattern
+
+To simplify API testing and handle various response types properly, we've implemented a global test agent pattern:
+
+### Available Global Agents
+
+Two pre-configured test agents are available globally in all test files:
+
+- `global.__TEST_AGENT__`: A standard Supertest agent that maintains cookies across requests
+- `global.__TEXT_AGENT__`: A specialized agent that properly handles text responses with JSON content-type
+
+### Using the Global Agents
+
+Import the global agents in your test files:
+
+```javascript
+describe('My API Test', () => {
+ // Access the global agents
+ const agent = global.__TEST_AGENT__;
+ const textAgent = global.__TEXT_AGENT__;
+
+ test('Test with JSON responses', async () => {
+ // Use standard agent for proper JSON responses
+ const response = await agent.get('/api/v3/conversations');
+ expect(response.status).toBe(200);
+ });
+
+ test('Test with text/error responses', async () => {
+ // Use text agent for endpoints that return text errors
+ const response = await textAgent.post('/api/v3/auth/login').send({});
+ expect(response.status).toBe(400);
+ expect(response.text).toContain('polis_err_param_missing_password');
+ });
+});
+```
+
+### Helper Functions
+
+You can use these standalone helper functions:
+
+- `makeTextRequest(app, method, path)`: Creates a single request with text parsing
+- `createTextAgent(app)`: Creates an agent with text parsing
+- `authenticateAgent(agent, token)`: Authenticates a single agent with a token
+- `authenticateGlobalAgents(token)`: Authenticates both global agents with the same token
+- `parseResponseJSON(response)`: Safely parses JSON response objects
+
+And these agent-based versions of common test operations:
+
+- `createComment(agent, conversationId, options)`: Creates a comment using an agent
+- `createConversation(agent, options)`: Creates a conversation using an agent
+- `getComments(agent, conversationId, options)`: Gets comments using an agent
+- `submitVote(agent, options)`: Submits a vote using an agent
+- `setupAuthAndConvo(options)`: Sets up auth and creates a conversation using agents
+
+See `__tests__/integration/example-global-agent.test.js` for a full example of this pattern.
+
+### Best Practices
+
+1. First determine if a `.test.supertest.js` version should be created for parallel testing, or if the original file should be updated directly.
+
+2. Replace direct `http` or `request` imports with the global agents:
+
+```javascript
+// Access the global agents
+const agent = global.__TEST_AGENT__; // For JSON responses
+const textAgent = global.__TEXT_AGENT__; // For handling text responses
+```
+
+3. Replace direct HTTP requests with agent requests:
+
+```javascript
+// Before:
+const response = await makeRequest('GET', '/conversations', null, authToken);
+
+// After:
+const response = await agent.get('/api/v3/conversations');
+```
+
+4. Be careful with response handling:
+ - Use `JSON.parse(response.text)` instead of `response.body` if needed
+ - For text responses, use `response.text` directly
+ - Use `textAgent` for endpoints that might return text errors
+
+5. Ensure cookies are properly handled when sharing sessions:
+
+```javascript
+// Set cookies on the agent
+const cookieString = cookies.map(c => c.split(';')[0]).join('; ');
+agent.set('Cookie', cookieString);
+```
+
+- Use `textAgent` for endpoints that might return error messages as text, even with a JSON content-type
+- Use `agent` for endpoints that reliably return valid JSON
+- For requests that need both cookie persistence and text handling, set the cookies on both agents
+- Use template literals for URL parameters: `` `/api/v3/nextComment?conversation_id=${conversationId}` ``
+- Don't forget the `/api/v3` prefix in routes when using the agents directly
+
+### Running Tests
+
+You can now run multiple test files without port conflicts:
+
+```bash
+npm test -- __tests__/integration/comment.test.js
+npm test -- __tests__/integration/vote.test.js
+```
+
+Or run all integration tests at once:
+
+```bash
+npm test -- __tests__/integration
+```
+
+Or, simply:
+
+```bash
+npm run test:integration
+```
+
+### Implementation Details
+
+The key changes were:
+
+1. Created `index.js` with a `startServer()` function
+2. Updated `app.js` to only export the configured app
+3. Modified `globalSetup.js` to start a server on a random port
+4. Enhanced `globalTeardown.js` to properly close the server
+5. Updated test helpers to use the dynamic port
+
+## Shared Test Agents
+
+To improve test reliability and performance, we use shared test agents across all test files. This is implemented using two key techniques:
+
+### 1. Global Agents with Lazy Initialization
+
+- Global agent instances are stored in `global.__TEST_AGENT__` and `global.__TEXT_AGENT__`
+- Helper functions `getTestAgent()` and `getTextAgent()` ensure agents are always available
+- Lazy initialization creates agents only when needed
+
+### 2. Lifecycle Management
+
+- `globalSetup.js` creates a test server on a dynamic port and initializes agents if needed
+- `globalTeardown.js` closes the server but preserves agent instances
+- This allows agents to maintain their state (cookies, etc.) across test files
+
+### Using Agents in Tests
+
+Always use the getter functions to access agents:
+
+```javascript
+import { getTestAgent } from '../setup/api-test-helpers.js';
+
+describe('My Test Suite', () => {
+ test('My Test', async () => {
+ const agent = await getTestAgent();
+ const response = await agent.get('/api/v3/endpoint');
+ expect(response.status).toBe(200);
+ });
+});
+```
+
+Or use the helper functions that utilize agents internally:
+
+```javascript
+import { createComment, getTestAgent } from '../setup/api-test-helpers.js';
+
+describe('My Test Suite', () => {
+ test('My Test', async () => {
+ const agent = await getTestAgent();
+ const commentId = await createComment(agent, conversationId, { txt: 'Test comment' });
+ expect(commentId).toBeDefined();
+ });
+});
+```
diff --git a/server/__tests__/integration/auth.test.ts b/server/__tests__/integration/auth.test.ts
new file mode 100644
index 0000000000..d212659565
--- /dev/null
+++ b/server/__tests__/integration/auth.test.ts
@@ -0,0 +1,289 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import {
+ extractCookieValue,
+ generateTestUser,
+ getTestAgent,
+ getTextAgent,
+ initializeParticipant,
+ initializeParticipantWithXid,
+ setupAuthAndConvo,
+ submitVote
+} from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { TestUser, VoteResponse as ActualVoteResponse } from '../../types/test-helpers';
+import { Agent } from 'supertest';
+
+interface UserResponse {
+ uid: number;
+ email: string;
+ hname?: string;
+ [key: string]: any;
+}
+
+interface ParticipantResponse {
+ agent: Agent;
+ body: {
+ conversation: {
+ conversation_id: string;
+ [key: string]: any;
+ };
+ nextComment: {
+ tid: number;
+ [key: string]: any;
+ };
+ [key: string]: any;
+ };
+ cookies: string[] | string | undefined;
+ status: number;
+}
+
+describe('Authentication with Supertest', () => {
+ // Define agents
+ let agent: Agent;
+ let textAgent: Agent;
+ const testUser: TestUser = generateTestUser();
+
+ // Initialize agents before tests
+ beforeAll(async () => {
+ agent = await getTestAgent();
+ textAgent = await getTextAgent();
+ });
+
+ describe('Login Endpoint', () => {
+ test('should validate login parameters', async () => {
+ // Test missing password
+ const noPasswordResponse: Response = await textAgent.post('/api/v3/auth/login').send({});
+ expect(noPasswordResponse.status).toBe(400);
+ expect(noPasswordResponse.text).toContain('polis_err_param_missing_password');
+
+ // Test missing email
+ const noEmailResponse: Response = await textAgent.post('/api/v3/auth/login').send({ password: 'testpass' });
+ expect(noEmailResponse.status).toBe(403);
+ expect(noEmailResponse.text).toMatch(/polis_err_login_unknown_user_or_password_noresults/);
+
+ // Test invalid credentials
+ const invalidResponse: Response = await textAgent.post('/api/v3/auth/login').send({
+ email: 'nonexistent@example.com',
+ password: 'wrongpassword'
+ });
+ expect(invalidResponse.status).toBe(403);
+ expect(invalidResponse.text).toContain('polis_err_login_unknown_user_or_password');
+ });
+ });
+
+ describe('Registration Endpoint', () => {
+ const validRegistration = {
+ email: `test-${Date.now()}@example.com`,
+ password: 'testPassword123!',
+ password2: 'testPassword123!',
+ hname: 'Test User',
+ gatekeeperTosPrivacy: true
+ };
+
+ test('should validate registration parameters', async () => {
+ // Test password mismatch
+ const mismatchResponse: Response = await textAgent.post('/api/v3/auth/new').send({
+ ...validRegistration,
+ password2: 'DifferentPassword123!'
+ });
+ expect(mismatchResponse.status).toBe(400);
+ expect(mismatchResponse.text).toContain('Passwords do not match');
+
+ // Test missing required fields
+ const missingFieldsResponse: Response = await textAgent.post('/api/v3/auth/new').send({
+ email: validRegistration.email
+ });
+ expect(missingFieldsResponse.status).toBe(400);
+ expect(missingFieldsResponse.text).toContain('polis_err_reg_need_tos');
+
+ // Test terms not accepted
+ const noTosResponse: Response = await textAgent.post('/api/v3/auth/new').send({
+ ...validRegistration,
+ gatekeeperTosPrivacy: false
+ });
+ expect(noTosResponse.status).toBe(400);
+ expect(noTosResponse.text).toContain('polis_err_reg_need_tos');
+ });
+ });
+
+ describe('Deregister (Logout) Endpoint', () => {
+ test('should handle logout parameters', async () => {
+ // Test missing showPage
+ const noShowPageResponse: Response = await textAgent.post('/api/v3/auth/deregister').send({});
+ expect(noShowPageResponse.status).toBe(200);
+
+ // Test null showPage
+ const nullShowPageResponse: Response = await textAgent.post('/api/v3/auth/deregister').send({
+ showPage: null
+ });
+ expect(nullShowPageResponse.status).toBe(200);
+ });
+ });
+
+ describe('Register-Login Flow', () => {
+ test('should complete full registration and login flow', async () => {
+ // STEP 1: Register a new user
+ const registerResponse: Response = await agent.post('/api/v3/auth/new').send({
+ email: testUser.email,
+ password: testUser.password,
+ password2: testUser.password,
+ hname: testUser.hname,
+ gatekeeperTosPrivacy: true
+ });
+
+ expect(registerResponse.status).toBe(200);
+ const registerBody = JSON.parse(registerResponse.text) as UserResponse;
+ expect(registerBody).toHaveProperty('uid');
+ expect(registerBody).toHaveProperty('email', testUser.email);
+ const userId = registerBody.uid;
+
+ // STEP 2: Login with registered user
+ const loginResponse: Response = await agent.post('/api/v3/auth/login').send({
+ email: testUser.email,
+ password: testUser.password
+ });
+
+ expect(loginResponse.status).toBe(200);
+ const loginBody = JSON.parse(loginResponse.text) as UserResponse;
+ expect(loginBody).toHaveProperty('uid', userId);
+ expect(loginBody).toHaveProperty('email', testUser.email);
+
+ const authCookies = loginResponse.headers['set-cookie'];
+ expect(authCookies).toBeDefined();
+ expect(authCookies!.length).toBeGreaterThan(0);
+
+ const token = extractCookieValue(authCookies, 'token2');
+ expect(token).toBeDefined();
+ });
+ });
+
+ describe('Complete Auth Flow', () => {
+ test('should handle complete auth lifecycle', async () => {
+ const completeFlowUser: TestUser = generateTestUser();
+
+ // STEP 1: Register new user
+ const registerResponse: Response = await agent.post('/api/v3/auth/new').send({
+ email: completeFlowUser.email,
+ password: completeFlowUser.password,
+ password2: completeFlowUser.password,
+ hname: completeFlowUser.hname,
+ gatekeeperTosPrivacy: true
+ });
+
+ expect(registerResponse.status).toBe(200);
+ const registerBody = JSON.parse(registerResponse.text) as UserResponse;
+ expect(registerBody).toHaveProperty('uid');
+
+ // STEP 2: Login user (agent maintains cookies)
+ const loginResponse: Response = await agent.post('/api/v3/auth/login').send({
+ email: completeFlowUser.email,
+ password: completeFlowUser.password
+ });
+
+ expect(loginResponse.status).toBe(200);
+ const authCookies = loginResponse.headers['set-cookie'];
+ expect(authCookies).toBeDefined();
+ expect(authCookies!.length).toBeGreaterThan(0);
+
+ // STEP 3: Logout user
+ const logoutResponse: Response = await textAgent.post('/api/v3/auth/deregister').send({});
+ expect(logoutResponse.status).toBe(200);
+
+ // STEP 4: Verify protected resource access fails
+ const protectedResponse: Response = await textAgent.get('/api/v3/conversations');
+ expect(protectedResponse.status).toBe(403);
+ expect(protectedResponse.text).toContain('polis_err_need_auth');
+
+ // STEP 5: Verify can login again
+ const reloginResponse: Response = await agent.post('/api/v3/auth/login').send({
+ email: completeFlowUser.email,
+ password: completeFlowUser.password
+ });
+
+ expect(reloginResponse.status).toBe(200);
+ expect(reloginResponse.headers['set-cookie']).toBeDefined();
+ expect(reloginResponse.headers['set-cookie']!.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('Participant Authentication', () => {
+ let conversationId: string;
+ let commentId: number;
+
+ beforeAll(async () => {
+ // Create owner and conversation using the agent helper function
+ const setup = await setupAuthAndConvo();
+
+ conversationId = setup.conversationId;
+ commentId = setup.commentIds[0];
+ });
+
+ test('should initialize participant session', async () => {
+ // Initialize participant
+ const { body, cookies, status }: ParticipantResponse = await initializeParticipant(conversationId);
+
+ expect(status).toBe(200);
+ expect(cookies).toBeDefined();
+ expect(cookies!.length).toBeGreaterThan(0);
+
+ const pcCookie = extractCookieValue(cookies, 'pc');
+ expect(pcCookie).toBeDefined();
+
+ expect(body).toHaveProperty('conversation');
+ expect(body).toHaveProperty('nextComment');
+ expect(body.conversation.conversation_id).toBe(conversationId);
+ expect(body.nextComment.tid).toBe(commentId);
+ });
+
+ test('should authenticate participant upon voting', async () => {
+ // STEP 1: Initialize participant
+ const { agent, cookies, status }: ParticipantResponse = await initializeParticipant(conversationId);
+
+ expect(status).toBe(200);
+ expect(cookies!.length).toBeGreaterThan(0);
+
+ // STEP 2: Submit vote
+ const voteResponse: ActualVoteResponse = await submitVote(agent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: -1
+ });
+
+ expect(voteResponse.status).toBe(200);
+
+ expect(voteResponse.body).toHaveProperty('currentPid');
+
+ // Verify participant cookies
+ expect(voteResponse.cookies!.length).toBeGreaterThan(0);
+
+ const uc = extractCookieValue(voteResponse.cookies, 'uc');
+ const uid2 = extractCookieValue(voteResponse.cookies, 'uid2');
+ const token2 = extractCookieValue(voteResponse.cookies, 'token2');
+
+ expect(uc).toBeDefined();
+ expect(uid2).toBeDefined();
+ expect(token2).toBeDefined();
+ });
+
+ test('should initialize participant with XID', async () => {
+ const xid = `test-xid-${Date.now()}`;
+ const { agent, body, cookies, status }: ParticipantResponse =
+ await initializeParticipantWithXid(conversationId, xid);
+
+ expect(status).toBe(200);
+ expect(cookies!.length).toBeGreaterThan(0);
+
+ expect(body).toHaveProperty('conversation');
+ expect(body).toHaveProperty('nextComment');
+
+ // Submit a vote to verify XID association works
+ const voteResponse: ActualVoteResponse = await submitVote(agent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: 1
+ });
+
+ expect(voteResponse.status).toBe(200);
+ });
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/comment-extended.test.ts b/server/__tests__/integration/comment-extended.test.ts
new file mode 100644
index 0000000000..402885edc4
--- /dev/null
+++ b/server/__tests__/integration/comment-extended.test.ts
@@ -0,0 +1,248 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import {
+ createComment,
+ getTestAgent,
+ getTextAgent,
+ initializeParticipant,
+ setupAuthAndConvo,
+ submitVote
+} from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+
+interface Comment {
+ tid: number;
+ txt: string;
+ active?: boolean;
+ mod?: number;
+ is_meta?: boolean;
+ velocity?: number;
+ [key: string]: any;
+}
+
+interface VoteResponse {
+ currentPid: string;
+ [key: string]: any;
+}
+
+describe('Extended Comment Endpoints', () => {
+ let conversationId: string;
+ let commentId: number;
+ let agent: Agent;
+ let textAgent: Agent;
+
+ beforeAll(async () => {
+ agent = await getTestAgent();
+ textAgent = await getTextAgent();
+
+ // Set up auth and conversation with comments
+ const setup = await setupAuthAndConvo({ commentCount: 1 });
+ conversationId = setup.conversationId;
+ commentId = setup.commentIds[0];
+ });
+
+ test('GET /comments with tids - Get specific comment by ID', async () => {
+ // Create a new comment to ensure clean test data
+ const timestamp = Date.now();
+ const commentText = `Test comment for individual retrieval ${timestamp}`;
+ const newCommentId: number = await createComment(agent, conversationId, {
+ txt: commentText
+ });
+
+ // Retrieve the specific comment by ID using the tids parameter
+ const commentsResponse: Response = await agent.get(`/api/v3/comments?conversation_id=${conversationId}&tids=${newCommentId}`);
+
+ expect(commentsResponse.status).toBe(200);
+ const comments: Comment[] = JSON.parse(commentsResponse.text);
+
+ // Validate response
+ expect(Array.isArray(comments)).toBe(true);
+ expect(comments.length).toBe(1);
+
+ const [comment] = comments;
+ expect(comment).toBeDefined();
+ expect(comment.tid).toBe(newCommentId);
+ expect(comment.txt).toBe(commentText);
+ });
+
+ test('GET /comments with non-existent tid returns empty array', async () => {
+ // Request a comment with an invalid ID
+ const nonExistentId: number = 999999999;
+ const commentsResponse: Response = await agent.get(
+ `/api/v3/comments?conversation_id=${conversationId}&tids=${nonExistentId}`
+ );
+
+ expect(commentsResponse.status).toBe(200);
+ const comments: Comment[] = JSON.parse(commentsResponse.text);
+
+ // Validate response - should be an empty array
+ expect(Array.isArray(comments)).toBe(true);
+ expect(comments.length).toBe(0);
+ });
+
+ test('PUT /comments - Moderate a comment', async () => {
+ // Create a new comment to test moderation
+ const timestamp = Date.now();
+ const commentText = `Comment for moderation test ${timestamp}`;
+ const moderationCommentId: number = await createComment(agent, conversationId, {
+ txt: commentText
+ });
+
+ // Moderate the comment - this endpoint is for moderation, not updating text
+ const updateResponse: Response = await agent.put('/api/v3/comments').send({
+ tid: moderationCommentId,
+ conversation_id: conversationId,
+ active: true, // Required - determines if comment is active
+ mod: 1, // Required - moderation status (0=ok, 1=hidden, etc.)
+ is_meta: false, // Required - meta comment flag
+ velocity: 1 // Required - comment velocity (0-1)
+ });
+
+ // Validate update response
+ expect(updateResponse.status).toBe(200);
+
+ // Get the comment to verify the moderation
+ const commentsResponse: Response = await agent.get(
+ `/api/v3/comments?conversation_id=${conversationId}&tids=${moderationCommentId}`
+ );
+
+ expect(commentsResponse.status).toBe(200);
+ const comments: Comment[] = JSON.parse(commentsResponse.text);
+
+ // Validate get response
+ expect(Array.isArray(comments)).toBe(true);
+ expect(comments.length).toBe(1);
+
+ const [moderatedComment] = comments;
+ expect(moderatedComment.tid).toBe(moderationCommentId);
+ // Original text should remain unchanged as this endpoint only updates moderation status
+ expect(moderatedComment.txt).toBe(commentText);
+ });
+
+ test('PUT /comments - Validation fails for missing required fields', async () => {
+ // Try to update a comment with missing required fields
+ const response: Response = await textAgent.put('/api/v3/comments').send({
+ // Missing various required fields
+ tid: commentId,
+ conversation_id: conversationId
+ // Missing: active, mod, is_meta, velocity
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.text).toMatch(/polis_err_param_missing/);
+ });
+
+ test('GET /comments - Filtering by multiple parameters', async () => {
+ // Create multiple comments with different attributes
+ const comment1Id: number = await createComment(agent, conversationId, {
+ txt: `Comment for filtering test 1 ${Date.now()}`
+ });
+
+ const comment2Id: number = await createComment(agent, conversationId, {
+ txt: `Comment for filtering test 2 ${Date.now()}`
+ });
+
+ const comment3Id: number = await createComment(agent, conversationId, {
+ txt: `Comment for filtering test 3 ${Date.now()}`
+ });
+
+ // Moderate comment 2
+ const moderateResponse: Response = await agent.put('/api/v3/comments').send({
+ tid: comment2Id,
+ conversation_id: conversationId,
+ active: true,
+ mod: 1,
+ is_meta: false,
+ velocity: 1
+ });
+
+ expect(moderateResponse.status).toBe(200);
+
+ // Test filtering by specific tids
+ const filteredByTidsResponse: Response = await agent.get(
+ `/api/v3/comments?conversation_id=${conversationId}&tids=${comment2Id},${comment3Id}`
+ );
+
+ expect(filteredByTidsResponse.status).toBe(200);
+ const filteredByTids: Comment[] = JSON.parse(filteredByTidsResponse.text);
+
+ expect(Array.isArray(filteredByTids)).toBe(true);
+ expect(filteredByTids.length).toBe(2);
+
+ // The comment IDs we just created should be in the results
+ const filteredCommentIds = filteredByTids.map((c) => c.tid);
+ expect(filteredCommentIds).toContain(comment2Id);
+ expect(filteredCommentIds).toContain(comment3Id);
+
+ // Test filtering by moderation status and tids
+ const filteredByModResponse: Response = await agent.get(
+ `/api/v3/comments?conversation_id=${conversationId}&tids=${comment1Id},${comment2Id},${comment3Id}&mod=1`
+ );
+
+ expect(filteredByModResponse.status).toBe(200);
+ const filteredByMod: Comment[] = JSON.parse(filteredByModResponse.text);
+
+ expect(Array.isArray(filteredByMod)).toBe(true);
+ expect(filteredByMod.length).toBe(1);
+
+ // The comment ID we just moderated should be in the results
+ const moderatedCommentIds = filteredByMod.map((c) => c.tid);
+ expect(moderatedCommentIds).toContain(comment2Id);
+ });
+
+ test('GET /comments - Filtering by not_voted_by_pid parameter', async () => {
+ // Create two new comments
+ const comment1Id: number = await createComment(agent, conversationId, {
+ txt: `Comment for not_voted_by_pid test 1 ${Date.now()}`
+ });
+
+ const comment2Id: number = await createComment(agent, conversationId, {
+ txt: `Comment for not_voted_by_pid test 2 ${Date.now()}`
+ });
+
+ // Initialize a participant
+ const { agent: participantAgent } = await initializeParticipant(conversationId);
+
+ // Vote on one of the comments as the participant
+ const voteResponse: Response = await submitVote(participantAgent, {
+ tid: comment1Id,
+ conversation_id: conversationId,
+ vote: 1 // 1 is disagree in this system
+ });
+
+ expect(voteResponse.status).toBe(200);
+
+ const voteData = voteResponse.body as VoteResponse;
+ expect(voteData).toHaveProperty('currentPid');
+ const currentPid: string = voteData.currentPid;
+
+ // Get comments not voted on by this participant
+ const notVotedResponse: Response = await agent.get(
+ `/api/v3/comments?conversation_id=${conversationId}¬_voted_by_pid=${currentPid}`
+ );
+
+ expect(notVotedResponse.status).toBe(200);
+ const notVotedComments: Comment[] = JSON.parse(notVotedResponse.text);
+
+ // Should only return the second comment (not voted on)
+ expect(Array.isArray(notVotedComments)).toBe(true);
+
+ // Confirm comment1Id is not in the results (since we voted on it)
+ const returnedIds = notVotedComments.map((c) => c.tid);
+ expect(returnedIds).not.toContain(comment1Id);
+
+ // Confirm comment2Id is in the results (since we didn't vote on it)
+ expect(returnedIds).toContain(comment2Id);
+ });
+
+ test('GET /comments/translations - returns 400 for missing conversation_id', async () => {
+ const response: Response = await agent.get(
+ `/api/v3/comments/translations?conversation_id=${conversationId}&tid=${commentId}&lang=en`
+ );
+
+ // NOTE: The legacy implementation has a bug (does not use moveToBody for GET params)
+ // so it is expected to always return a 400 error
+ expect(response.status).toBe(400);
+ expect(response.text).toMatch(/polis_err_param_missing_conversation_id/);
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/comment.test.ts b/server/__tests__/integration/comment.test.ts
new file mode 100644
index 0000000000..0accbb6750
--- /dev/null
+++ b/server/__tests__/integration/comment.test.ts
@@ -0,0 +1,123 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import {
+ createComment,
+ generateRandomXid,
+ getTestAgent,
+ getTextAgent,
+ initializeParticipant,
+ initializeParticipantWithXid,
+ setupAuthAndConvo
+} from '../setup/api-test-helpers';
+
+interface Comment {
+ tid: number;
+ txt: string;
+ conversation_id: string;
+ created: number;
+ [key: string]: any;
+}
+
+describe('Comment Endpoints', () => {
+ // Declare agent variables
+ let agent: Agent;
+ let textAgent: Agent;
+ let conversationId: string | null = null;
+
+ beforeAll(async () => {
+ // Initialize agents
+ agent = await getTestAgent();
+ textAgent = await getTextAgent();
+
+ // Setup auth and create test conversation
+ const setup = await setupAuthAndConvo();
+ conversationId = setup.conversationId;
+ });
+
+ test('Comment lifecycle', async () => {
+ // STEP 1: Create a new comment
+ const timestamp = Date.now();
+ const commentText = `Test comment ${timestamp}`;
+ const commentId = await createComment(agent, conversationId!, {
+ conversation_id: conversationId!,
+ txt: commentText
+ });
+
+ expect(commentId).toBeDefined();
+
+ // STEP 2: Verify comment appears in conversation
+ const listResponse: Response = await agent.get(`/api/v3/comments?conversation_id=${conversationId}`);
+ expect(listResponse.status).toBe(200);
+ const responseBody: Comment[] = JSON.parse(listResponse.text);
+ expect(Array.isArray(responseBody)).toBe(true);
+ const foundComment = responseBody.find((comment) => comment.tid === commentId);
+ expect(foundComment).toBeDefined();
+ expect(foundComment!.txt).toBe(commentText);
+ });
+
+ test('Comment validation', async () => {
+ // Test invalid conversation ID
+ const invalidResponse = await textAgent.post('/api/v3/comments').send({
+ conversation_id: 'invalid-conversation-id',
+ txt: 'This comment should fail'
+ });
+
+ expect(invalidResponse.status).toBe(400);
+
+ // Test missing conversation ID in comments list
+ const missingConvResponse = await agent.get('/api/v3/comments');
+ expect(missingConvResponse.status).toBe(400);
+ });
+
+ test('Anonymous participant can submit a comment', async () => {
+ // Initialize anonymous participant
+ const { agent } = await initializeParticipant(conversationId!);
+
+ // Create a comment as anonymous participant using the helper
+ const timestamp = Date.now();
+ const commentText = `Anonymous participant comment ${timestamp}`;
+ const commentId = await createComment(agent, conversationId!, {
+ conversation_id: conversationId!,
+ txt: commentText
+ });
+
+ expect(commentId).toBeDefined();
+
+ // Verify the comment appears in the conversation
+ const listResponse: Response = await agent.get(`/api/v3/comments?conversation_id=${conversationId}`);
+
+ expect(listResponse.status).toBe(200);
+ const responseBody: Comment[] = JSON.parse(listResponse.text);
+ expect(Array.isArray(responseBody)).toBe(true);
+ const foundComment = responseBody.find((comment) => comment.tid === commentId);
+ expect(foundComment).toBeDefined();
+ expect(foundComment!.txt).toBe(commentText);
+ });
+
+ test('XID participant can submit a comment', async () => {
+ // Initialize participant with XID
+ const xid = generateRandomXid();
+ const { agent } = await initializeParticipantWithXid(conversationId!, xid);
+
+ // Create a comment as XID participant using the helper
+ const timestamp = Date.now();
+ const commentText = `XID participant comment ${timestamp}`;
+ const commentId = await createComment(agent, conversationId!, {
+ conversation_id: conversationId!,
+ txt: commentText
+ });
+
+ expect(commentId).toBeDefined();
+
+ // Verify the comment appears in the conversation
+ const listResponse: Response = await agent.get(`/api/v3/comments?conversation_id=${conversationId}`);
+
+ expect(listResponse.status).toBe(200);
+ const responseBody: Comment[] = JSON.parse(listResponse.text);
+ expect(Array.isArray(responseBody)).toBe(true);
+ const foundComment = responseBody.find((comment) => comment.tid === commentId);
+ expect(foundComment).toBeDefined();
+ expect(foundComment!.txt).toBe(commentText);
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/contexts.test.ts b/server/__tests__/integration/contexts.test.ts
new file mode 100644
index 0000000000..2117e80a58
--- /dev/null
+++ b/server/__tests__/integration/contexts.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test, beforeAll } from '@jest/globals';
+import { generateTestUser, newAgent, registerAndLoginUser, getTestAgent } from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { AuthData } from '../../types/test-helpers';
+import { Agent } from 'supertest';
+
+interface Context {
+ name: string;
+ [key: string]: any;
+}
+
+describe('GET /contexts', () => {
+ let agent: Agent;
+
+ // Initialize the agent before tests run
+ beforeAll(async () => {
+ agent = await newAgent();
+ });
+
+ test('Returns available contexts to anonymous users', async () => {
+ // Call the contexts endpoint
+ const response: Response = await agent.get('/api/v3/contexts');
+
+ // Verify response status is 200
+ expect(response.status).toBe(200);
+
+ // Verify response contains expected keys
+ expect(response.body).toBeDefined();
+ expect(Array.isArray(response.body)).toBe(true);
+
+ // Each context should have basic properties
+ if (response.body.length > 0) {
+ const context = response.body[0] as Context;
+ expect(context).toHaveProperty('name');
+ }
+ });
+
+ test('Returns available contexts to authenticated users', async () => {
+ // Register and login a test user
+ const testUser = generateTestUser();
+ const auth: AuthData = await registerAndLoginUser(testUser);
+ const authAgent = auth.agent;
+
+ // Call the contexts endpoint with authentication
+ const response: Response = await authAgent.get('/api/v3/contexts');
+
+ // Verify response status is 200
+ expect(response.status).toBe(200);
+
+ // Verify response contains an array of contexts
+ expect(Array.isArray(response.body)).toBe(true);
+
+ // Each context should have basic properties
+ if (response.body.length > 0) {
+ const context = response.body[0] as Context;
+ expect(context).toHaveProperty('name');
+ }
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/conversation-activity.test.ts b/server/__tests__/integration/conversation-activity.test.ts
new file mode 100644
index 0000000000..0262f74142
--- /dev/null
+++ b/server/__tests__/integration/conversation-activity.test.ts
@@ -0,0 +1,31 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import { registerAndLoginUser } from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import type { AuthData } from '../../types/test-helpers';
+
+describe('Conversation Activity API', () => {
+ let textAgent: Agent;
+
+ beforeAll(async () => {
+ // Register a regular user
+ const auth: AuthData = await registerAndLoginUser();
+ textAgent = auth.textAgent;
+ });
+
+ test('GET /api/v3/conversations/recent_activity - should return 403 for non-admin users', async () => {
+ const response: Response = await textAgent.get('/api/v3/conversations/recent_activity');
+ expect(response.status).toBe(403);
+ expect(response.text).toContain('polis_err_no_access_for_this_user');
+ });
+
+ test('GET /api/v3/conversations/recently_started with sinceUnixTimestamp - should return 403', async () => {
+ // Get current time in seconds
+ const currentTimeInSeconds: number = Math.floor(Date.now() / 1000);
+ const timeOneWeekAgo: number = currentTimeInSeconds - 7 * 24 * 60 * 60;
+
+ const response: Response = await textAgent.get(`/api/v3/conversations/recently_started?sinceUnixTimestamp=${timeOneWeekAgo}`);
+ expect(response.status).toBe(403);
+ expect(response.text).toContain('polis_err_no_access_for_this_user');
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/conversation-details.test.ts b/server/__tests__/integration/conversation-details.test.ts
new file mode 100644
index 0000000000..a1ad701197
--- /dev/null
+++ b/server/__tests__/integration/conversation-details.test.ts
@@ -0,0 +1,151 @@
+import { beforeEach, describe, expect, test } from '@jest/globals';
+import {
+ createComment,
+ createConversation,
+ generateTestUser,
+ getTestAgent,
+ newAgent,
+ registerAndLoginUser
+} from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import type { AuthData, TestUser } from '../../types/test-helpers';
+
+interface Conversation {
+ conversation_id: string;
+ topic: string;
+ description?: string;
+ is_active?: boolean;
+ is_anon?: boolean;
+ [key: string]: any;
+}
+
+interface ConversationStats {
+ voteTimes: any[];
+ firstVoteTimes: any[];
+ commentTimes: any[];
+ firstCommentTimes: any[];
+ votesHistogram: any;
+ burstHistogram: any;
+ [key: string]: any;
+}
+
+describe('Conversation Details API', () => {
+ let agent: Agent;
+
+ beforeEach(async () => {
+ // Initialize agent
+ agent = await getTestAgent();
+
+ const testUser: TestUser = generateTestUser();
+ await registerAndLoginUser(testUser);
+ });
+
+ test('should retrieve conversation details using conversation_id', async () => {
+ // Create a public conversation
+ const conversationId: string = await createConversation(agent, {
+ is_active: true,
+ is_anon: true,
+ topic: 'Test Public Conversation',
+ description: 'This is a test public conversation for the details endpoint'
+ });
+
+ // Add a comment to the conversation
+ await createComment(agent, conversationId, {
+ txt: 'This is a test comment for the conversation'
+ });
+
+ const response: Response = await agent.get(`/api/v3/conversations?conversation_id=${conversationId}`);
+
+ // Check that the response is successful
+ expect(response.status).toBe(200);
+ // The endpoint returns one conversation when conversation_id is specified
+ expect(response.body).toBeDefined();
+ // Verify the conversation has the expected topic
+ const conversation = response.body as Conversation;
+ expect(conversation.topic).toBe('Test Public Conversation');
+ });
+
+ test('should retrieve conversation list for an authenticated user', async () => {
+ // Create a public conversation
+ const conversation1Id: string = await createConversation(agent, {
+ topic: 'My Test Conversation 1'
+ });
+
+ const conversation2Id: string = await createConversation(agent, {
+ topic: 'My Test Conversation 2'
+ });
+
+ // Fetch conversation list for the user - use the correct path without API_PREFIX
+ const response: Response = await agent.get('/api/v3/conversations');
+
+ // Check that the response is successful
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBe(2);
+
+ // Find our created conversation in the list
+ const conversations = response.body as Conversation[];
+ const foundConversation1 = conversations.find((conv) => conv.conversation_id === conversation1Id);
+ const foundConversation2 = conversations.find((conv) => conv.conversation_id === conversation2Id);
+
+ expect(foundConversation1).toBeDefined();
+ expect(foundConversation1?.topic).toBe('My Test Conversation 1');
+ expect(foundConversation2).toBeDefined();
+ expect(foundConversation2?.topic).toBe('My Test Conversation 2');
+ });
+
+ test('should retrieve public conversation by conversation_id', async () => {
+ // Create a public conversation
+ const conversationId: string = await createConversation(agent, {
+ is_active: true,
+ is_anon: true,
+ topic: 'Public Test Conversation',
+ description: 'This is a public test conversation'
+ });
+
+ const publicAgent = await newAgent();
+
+ // Fetch conversation details without auth token
+ const response: Response = await publicAgent.get(`/api/v3/conversations?conversation_id=${conversationId}`);
+
+ // Check that the response is successful
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+ const conversation = response.body as Conversation;
+ expect(conversation.topic).toBe('Public Test Conversation');
+ });
+
+ test('should return 400 for non-existent conversation', async () => {
+ // Try to fetch a conversation with an invalid ID
+ const response: Response = await agent.get('/api/v3/conversations?conversation_id=nonexistent-conversation-id');
+
+ // The endpoint returns a 400 error for a non-existent conversation
+ expect(response.status).toBe(400);
+ expect(response.text).toContain('polis_err_param_parse_failed_conversation_id');
+ expect(response.text).toContain('polis_err_fetching_zid_for_conversation_id');
+ });
+
+ test('should retrieve conversation stats', async () => {
+ // Create a public conversation
+ const conversationId: string = await createConversation(agent, {
+ is_active: true,
+ is_anon: true,
+ topic: 'Test Stats Conversation'
+ });
+
+ // Get conversation stats
+ const response: Response = await agent.get(`/api/v3/conversationStats?conversation_id=${conversationId}`);
+
+ // Check that the response is successful
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+ const stats = response.body as ConversationStats;
+ expect(stats.voteTimes).toBeDefined();
+ expect(stats.firstVoteTimes).toBeDefined();
+ expect(stats.commentTimes).toBeDefined();
+ expect(stats.firstCommentTimes).toBeDefined();
+ expect(stats.votesHistogram).toBeDefined();
+ expect(stats.burstHistogram).toBeDefined();
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/conversation-preload.test.ts b/server/__tests__/integration/conversation-preload.test.ts
new file mode 100644
index 0000000000..52c985f8b2
--- /dev/null
+++ b/server/__tests__/integration/conversation-preload.test.ts
@@ -0,0 +1,89 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import { createConversation, getTextAgent, registerAndLoginUser } from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import type { AuthData } from '../../types/test-helpers';
+
+interface ConversationPreloadResponse {
+ conversation_id: string;
+ topic: string;
+ description: string;
+ created: number;
+ vis_type: number;
+ write_type: number;
+ help_type: number;
+ socialbtn_type: number;
+ bgcolor: string;
+ help_color: string;
+ help_bgcolor: string;
+ style_btn: string;
+ auth_needed_to_vote: boolean;
+ auth_needed_to_write: boolean;
+ auth_opt_allow_3rdparty: boolean;
+ [key: string]: any;
+}
+
+describe('Conversation Preload API', () => {
+ let agent: Agent;
+ let textAgent: Agent;
+ let conversationId: string;
+
+ beforeAll(async () => {
+ // Register a user (conversation owner)
+ const auth: AuthData = await registerAndLoginUser();
+ agent = auth.agent;
+ textAgent = await getTextAgent();
+
+ // Create a conversation
+ conversationId = await createConversation(agent);
+ });
+
+ test('GET /api/v3/conversations/preload - should return preload info for a conversation', async () => {
+ const response: Response = await agent.get(`/api/v3/conversations/preload?conversation_id=${conversationId}`);
+ const { body, status } = response;
+
+ // Should return successful response
+ expect(status).toBe(200);
+
+ const preloadInfo = body as ConversationPreloadResponse;
+ expect(preloadInfo).toHaveProperty('conversation_id', conversationId);
+ expect(preloadInfo).toHaveProperty('topic');
+ expect(preloadInfo).toHaveProperty('description');
+ expect(preloadInfo).toHaveProperty('created');
+ expect(preloadInfo).toHaveProperty('vis_type');
+ expect(preloadInfo).toHaveProperty('write_type');
+ expect(preloadInfo).toHaveProperty('help_type');
+ expect(preloadInfo).toHaveProperty('socialbtn_type');
+ expect(preloadInfo).toHaveProperty('bgcolor');
+ expect(preloadInfo).toHaveProperty('help_color');
+ expect(preloadInfo).toHaveProperty('help_bgcolor');
+ expect(preloadInfo).toHaveProperty('style_btn');
+ expect(preloadInfo).toHaveProperty('auth_needed_to_vote', false);
+ expect(preloadInfo).toHaveProperty('auth_needed_to_write', false);
+ expect(preloadInfo).toHaveProperty('auth_opt_allow_3rdparty', true);
+ });
+
+ test('GET /api/v3/conversations/preload - should return 500 with invalid conversation_id', async () => {
+ const response: Response = await textAgent.get('/api/v3/conversations/preload?conversation_id=invalid_id');
+
+ // Should return error response
+ expect(response.status).toBe(500);
+ expect(response.text).toContain('polis_err_get_conversation_preload_info');
+ });
+
+ test('GET /api/v3/conversations/preload - should return 500 with non-existent conversation_id', async () => {
+ const response: Response = await textAgent.get('/api/v3/conversations/preload?conversation_id=99999999');
+
+ // Should return error response
+ expect(response.status).toBe(500);
+ expect(response.text).toContain('polis_err_get_conversation_preload_info');
+ });
+
+ test('GET /api/v3/conversations/preload - should require conversation_id parameter', async () => {
+ const response: Response = await textAgent.get('/api/v3/conversations/preload');
+
+ // Should return error response
+ expect(response.status).toBe(400);
+ expect(response.text).toContain('polis_err_param_missing_conversation_id');
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/conversation-stats.test.ts b/server/__tests__/integration/conversation-stats.test.ts
new file mode 100644
index 0000000000..db6eb2da11
--- /dev/null
+++ b/server/__tests__/integration/conversation-stats.test.ts
@@ -0,0 +1,112 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import type { Response } from 'supertest';
+import {
+ createComment,
+ createConversation,
+ initializeParticipant,
+ registerAndLoginUser,
+ submitVote,
+ newAgent
+} from '../setup/api-test-helpers';
+import type { AuthData } from '../../types/test-helpers';
+
+interface ConversationStats {
+ voteTimes: number[];
+ firstVoteTimes: number[];
+ commentTimes: number[];
+ firstCommentTimes: number[];
+ votesHistogram: any;
+ burstHistogram: any;
+ [key: string]: any;
+}
+
+describe('Conversation Stats API', () => {
+ let agent: ReturnType;
+ let conversationId: string;
+
+ beforeAll(async () => {
+ // Register a user (conversation owner)
+ const auth = await registerAndLoginUser();
+ agent = auth.agent;
+
+ // Create a conversation
+ conversationId = await createConversation(agent);
+
+ // Initialize a participant
+ const participantResult = await initializeParticipant(conversationId);
+ const participantAgent = participantResult.agent;
+
+ // Create a comment as the owner
+ const commentId = await createComment(agent, conversationId, {
+ conversation_id: conversationId,
+ txt: 'This is a test comment'
+ });
+
+ // Cast a vote as a participant
+ await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: 1
+ });
+ });
+
+ test('GET /api/v3/conversationStats - should return stats for conversation owner', async () => {
+ const response: Response = await agent.get(`/api/v3/conversationStats?conversation_id=${conversationId}`);
+
+ // Should return successful response
+ expect(response.status).toBe(200);
+
+ // Response should be JSON and contain stats data
+ const data: ConversationStats = JSON.parse(response.text);
+ expect(data).toHaveProperty('voteTimes');
+ expect(data).toHaveProperty('firstVoteTimes');
+ expect(data).toHaveProperty('commentTimes');
+ expect(data).toHaveProperty('firstCommentTimes');
+ expect(data).toHaveProperty('votesHistogram');
+ expect(data).toHaveProperty('burstHistogram');
+
+ // Should have one comment time
+ expect(data.commentTimes.length).toBe(1);
+
+ // Should have one vote time
+ expect(data.voteTimes.length).toBe(1);
+ });
+
+ test('GET /api/v3/conversationStats - should accept until parameter', async () => {
+ // Get current time in milliseconds
+ const currentTimeMs = Date.now();
+
+ const response: Response = await agent.get(
+ `/api/v3/conversationStats?conversation_id=${conversationId}&until=${currentTimeMs}`
+ );
+
+ // Should return successful response
+ expect(response.status).toBe(200);
+
+ // Response should be JSON and contain stats data
+ const data: ConversationStats = JSON.parse(response.text);
+
+ // All the data should be present because until is in the future
+ expect(data.commentTimes.length).toBe(1);
+ expect(data.voteTimes.length).toBe(1);
+ });
+
+ test('GET /api/v3/conversationStats - should filter data with until parameter', async () => {
+ // Get time from yesterday (before our test data was created)
+ const yesterdayMs = Date.now() - 24 * 60 * 60 * 1000;
+
+ const response: Response = await agent.get(
+ `/api/v3/conversationStats?conversation_id=${conversationId}&until=${yesterdayMs}`
+ );
+
+ // Should return successful response
+ expect(response.status).toBe(200);
+
+ // Response should be JSON and contain stats data with no entries
+ const data: ConversationStats = JSON.parse(response.text);
+
+ // No data should be present because until is in the past
+ expect(data.commentTimes.length).toBe(0);
+ expect(data.voteTimes.length).toBe(0);
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/conversation-update.test.ts b/server/__tests__/integration/conversation-update.test.ts
new file mode 100644
index 0000000000..34b1a17319
--- /dev/null
+++ b/server/__tests__/integration/conversation-update.test.ts
@@ -0,0 +1,192 @@
+import { beforeEach, describe, expect, test } from '@jest/globals';
+import {
+ createConversation,
+ generateTestUser,
+ getTestAgent,
+ registerAndLoginUser,
+ updateConversation
+} from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import type { TestUser } from '../../types/test-helpers';
+
+interface Conversation {
+ conversation_id: string;
+ topic: string;
+ description?: string;
+ is_active?: boolean;
+ strict_moderation?: boolean;
+ profanity_filter?: boolean;
+ bgcolor?: string | null;
+ help_color?: string | null;
+ help_bgcolor?: string | null;
+ [key: string]: any;
+}
+
+interface ConversationUpdateData {
+ conversation_id: string;
+ topic?: string;
+ description?: string;
+ is_active?: boolean;
+ strict_moderation?: boolean;
+ profanity_filter?: boolean;
+ bgcolor?: string;
+ help_color?: string;
+ help_bgcolor?: string;
+ [key: string]: any;
+}
+
+describe('Conversation Update API', () => {
+ let agent: Agent;
+ let testUser: TestUser;
+ let conversationId: string;
+
+ beforeEach(async () => {
+ // Initialize agent
+ agent = await getTestAgent();
+
+ // Create a test user for each test
+ testUser = generateTestUser();
+ await registerAndLoginUser(testUser);
+
+ // Create a test conversation for each test
+ conversationId = await createConversation(agent, {
+ is_active: true,
+ is_anon: true,
+ topic: 'Original Topic',
+ description: 'Original Description',
+ strict_moderation: false
+ });
+ });
+
+ test('should update basic conversation properties', async () => {
+ // Update the conversation with new values
+ const updateResponse: Response = await updateConversation(agent, {
+ conversation_id: conversationId,
+ topic: 'Updated Topic',
+ description: 'Updated Description'
+ });
+
+ // Verify update was successful
+ expect(updateResponse.status).toBe(200);
+
+ // Verify the changes by getting the conversation details
+ const getResponse: Response = await agent.get(`/api/v3/conversations?conversation_id=${conversationId}`);
+
+ expect(getResponse.status).toBe(200);
+ expect(getResponse.body).toBeDefined();
+ const conversation = getResponse.body as Conversation;
+ expect(conversation.topic).toBe('Updated Topic');
+ expect(conversation.description).toBe('Updated Description');
+ });
+
+ test('should update boolean settings', async () => {
+ // Update various boolean settings
+ const updateData: ConversationUpdateData = {
+ conversation_id: conversationId,
+ is_active: false,
+ strict_moderation: true,
+ profanity_filter: true
+ };
+
+ const updateResponse: Response = await updateConversation(agent, updateData);
+
+ // Verify update was successful
+ expect(updateResponse.status).toBe(200);
+
+ // Verify the changes by getting the conversation details
+ const getResponse: Response = await agent.get(`/api/v3/conversations?conversation_id=${conversationId}`);
+
+ expect(getResponse.status).toBe(200);
+ expect(getResponse.body).toBeDefined();
+ const conversation = getResponse.body as Conversation;
+ expect(conversation.is_active).toBe(false);
+ expect(conversation.strict_moderation).toBe(true);
+ expect(conversation.profanity_filter).toBe(true);
+ });
+
+ test('should update appearance settings', async () => {
+ // Update appearance settings
+ const updateData: ConversationUpdateData = {
+ conversation_id: conversationId,
+ bgcolor: '#f5f5f5',
+ help_color: '#333333',
+ help_bgcolor: '#ffffff'
+ };
+
+ const updateResponse: Response = await updateConversation(agent, updateData);
+
+ // Verify update was successful
+ expect(updateResponse.status).toBe(200);
+
+ // Verify the changes by getting the conversation details
+ const getResponse: Response = await agent.get(`/api/v3/conversations?conversation_id=${conversationId}`);
+
+ expect(getResponse.status).toBe(200);
+ expect(getResponse.body).toBeDefined();
+ const conversation = getResponse.body as Conversation;
+ expect(conversation.bgcolor).toBe('#f5f5f5');
+ expect(conversation.help_color).toBe('#333333');
+ expect(conversation.help_bgcolor).toBe('#ffffff');
+ });
+
+ test('should handle non-existent conversation', async () => {
+ const updateData: ConversationUpdateData = {
+ conversation_id: 'non-existent-conversation',
+ topic: 'This Should Fail'
+ };
+
+ const updateResponse: Response = await updateConversation(agent, updateData);
+
+ // Verify update fails appropriately
+ expect(updateResponse.status).not.toBe(200);
+ });
+
+ test('should reset appearance settings to default values', async () => {
+ // First, set some appearance values
+ await updateConversation(agent, {
+ conversation_id: conversationId,
+ bgcolor: '#f5f5f5',
+ help_color: '#333333'
+ });
+
+ // Then reset them to default
+ const updateData: ConversationUpdateData = {
+ conversation_id: conversationId,
+ bgcolor: 'default',
+ help_color: 'default'
+ };
+
+ const updateResponse: Response = await updateConversation(agent, updateData);
+
+ // Verify update was successful
+ expect(updateResponse.status).toBe(200);
+
+ // Verify the changes by getting the conversation details
+ const getResponse: Response = await agent.get(`/api/v3/conversations?conversation_id=${conversationId}`);
+
+ expect(getResponse.status).toBe(200);
+ expect(getResponse.body).toBeDefined();
+ const conversation = getResponse.body as Conversation;
+ expect(conversation.bgcolor).toBeNull();
+ expect(conversation.help_color).toBeNull();
+ });
+
+ test('should fail when updating conversation without permission', async () => {
+ // Create another user without permission to update the conversation
+ const unauthorizedUser: TestUser = generateTestUser();
+ const { textAgent: unauthorizedAgent } = await registerAndLoginUser(unauthorizedUser);
+
+ // Attempt to update the conversation
+ const updateData: ConversationUpdateData = {
+ conversation_id: conversationId,
+ topic: 'Unauthorized Topic Update'
+ };
+
+ const updateResponse: Response = await updateConversation(unauthorizedAgent, updateData);
+
+ // Verify update fails with permission error
+ expect(updateResponse.status).toBe(403);
+ expect(updateResponse.text).toMatch(/polis_err_update_conversation_permission/);
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/conversation.test.ts b/server/__tests__/integration/conversation.test.ts
new file mode 100644
index 0000000000..06ff894582
--- /dev/null
+++ b/server/__tests__/integration/conversation.test.ts
@@ -0,0 +1,88 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import { createConversation, getTestAgent, setupAuthAndConvo } from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+
+interface Conversation {
+ conversation_id: string;
+ topic: string;
+ description: string;
+ is_active: boolean;
+ is_draft: boolean;
+ owner: number;
+ created: string;
+ modified: string;
+ [key: string]: any;
+}
+
+describe('Conversation Endpoints', () => {
+ // Declare agent variable
+ let agent: Agent;
+
+ beforeAll(async () => {
+ // Initialize agent
+ agent = await getTestAgent();
+
+ // Setup auth without creating conversation
+ await setupAuthAndConvo({ createConvo: false });
+ });
+
+ test('Full conversation lifecycle', async () => {
+ // STEP 1: Create a new conversation
+ const timestamp = Date.now();
+ const conversationId = await createConversation(agent, {
+ topic: `Test Conversation ${timestamp}`,
+ description: `Test Description ${timestamp}`,
+ is_active: true,
+ is_draft: false
+ });
+
+ expect(conversationId).toBeDefined();
+
+ // STEP 2: Verify conversation appears in list
+ const listResponse: Response = await agent.get('/api/v3/conversations');
+
+ expect(listResponse.status).toBe(200);
+ const responseBody: Conversation[] = JSON.parse(listResponse.text);
+ expect(Array.isArray(responseBody)).toBe(true);
+ expect(responseBody.some((conv) => conv.conversation_id === conversationId)).toBe(true);
+
+ // STEP 3: Get conversation stats
+ const statsResponse: Response = await agent.get(`/api/v3/conversationStats?conversation_id=${conversationId}`);
+
+ expect(statsResponse.status).toBe(200);
+ expect(JSON.parse(statsResponse.text)).toBeDefined();
+
+ // STEP 4: Update conversation
+ const updateData = {
+ conversation_id: conversationId,
+ description: `Updated description ${timestamp}`,
+ topic: `Updated topic ${timestamp}`,
+ is_active: true,
+ is_draft: false
+ };
+
+ const updateResponse: Response = await agent.put('/api/v3/conversations').send(updateData);
+
+ expect(updateResponse.status).toBe(200);
+
+ // STEP 5: Close conversation
+ // NOTE: This endpoint may time out, which is actually expected behavior
+ try {
+ await agent.post('/api/v3/conversation/close').send({ conversation_id: conversationId }).timeout(3000); // Shorter timeout since we expect a potential timeout
+
+ // If we get here without error, that's fine
+ } catch (error) {
+ // Ignore timeout errors as they're expected
+ if (!(error as any).timeout) {
+ throw error; // Re-throw non-timeout errors
+ }
+ console.log('Close conversation timed out as expected');
+ }
+
+ // STEP 6: Reopen conversation
+ const reopenResponse: Response = await agent.post('/api/v3/conversation/reopen').send({ conversation_id: conversationId });
+
+ expect(reopenResponse.status).toBe(200);
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/data-export.test.ts b/server/__tests__/integration/data-export.test.ts
new file mode 100644
index 0000000000..e53f981c8e
--- /dev/null
+++ b/server/__tests__/integration/data-export.test.ts
@@ -0,0 +1,137 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import { createConversation, populateConversationWithVotes, registerAndLoginUser } from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import type { AuthData } from '../../types/test-helpers';
+
+interface TestData {
+ comments: number[];
+ stats: {
+ totalVotes: number;
+ [key: string]: any;
+ };
+ [key: string]: any;
+}
+
+describe('Data Export API', () => {
+ let agent: Agent;
+ let textAgent: Agent;
+ let conversationId: string;
+ let testData: TestData;
+ let reportId: string;
+
+ const numParticipants = 3;
+ const numComments = 3;
+ const testTopic = 'Test Data Export Conversation';
+ const testDescription = 'This is a test conversation created for data export testing';
+
+ beforeAll(async () => {
+ // Register a user (conversation owner)
+ const auth: AuthData = await registerAndLoginUser();
+ agent = auth.agent;
+ textAgent = auth.textAgent;
+
+ // Create a conversation
+ conversationId = await createConversation(agent, {
+ topic: testTopic,
+ description: testDescription
+ });
+
+ // Populate the conversation with test data
+ testData = await populateConversationWithVotes({
+ conversationId,
+ numParticipants,
+ numComments
+ });
+
+ // Create a report for this conversation
+ await agent.post('/api/v3/reports').send({
+ conversation_id: conversationId
+ });
+
+ // Get the report ID
+ const getReportsResponse: Response = await agent.get(`/api/v3/reports?conversation_id=${conversationId}`);
+ reportId = getReportsResponse.body[0].report_id;
+ });
+
+ test('GET /api/v3/dataExport - should initiate a data export task', async () => {
+ const currentTimeInSeconds: number = Math.floor(Date.now() / 1000);
+
+ const response: Response = await agent.get(
+ `/api/v3/dataExport?conversation_id=${conversationId}&unixTimestamp=${currentTimeInSeconds}&format=csv`
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({});
+ });
+
+ test('GET /api/v3/reportExport/:report_id/summary.csv - should export report summary', async () => {
+ const response: Response = await agent.get(`/api/v3/reportExport/${reportId}/summary.csv`);
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-type']).toContain('text/csv');
+
+ expect(response.text).toContain(`topic,"${testTopic}"`);
+ expect(response.text).toContain('url');
+ expect(response.text).toContain(`voters,${numParticipants}`);
+ expect(response.text).toContain(`voters-in-conv,${numParticipants}`);
+ expect(response.text).toContain('commenters,1'); // owner is the only commenter
+ expect(response.text).toContain(`comments,${numComments}`);
+ expect(response.text).toContain('groups,');
+ expect(response.text).toContain(`conversation-description,"${testDescription}"`);
+ });
+
+ test('GET /api/v3/reportExport/:report_id/comments.csv - should export comments', async () => {
+ const response: Response = await agent.get(`/api/v3/reportExport/${reportId}/comments.csv`);
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-type']).toContain('text/csv');
+
+ // Should contain expected headers
+ expect(response.text).toContain('timestamp');
+ expect(response.text).toContain('datetime');
+ expect(response.text).toContain('comment-id');
+ expect(response.text).toContain('author-id');
+ expect(response.text).toContain('agrees');
+ expect(response.text).toContain('disagrees');
+ expect(response.text).toContain('moderated');
+ expect(response.text).toContain('comment-body');
+
+ // Should contain all our test comments
+ testData.comments.forEach((commentId) => {
+ expect(response.text).toContain(commentId.toString());
+ });
+ });
+
+ test('GET /api/v3/reportExport/:report_id/votes.csv - should export votes', async () => {
+ const response: Response = await textAgent.get(`/api/v3/reportExport/${reportId}/votes.csv`);
+
+ expect(response.status).toBe(200);
+ expect(response.headers['content-type']).toContain('text/csv');
+
+ // Should contain expected headers
+ expect(response.text).toContain('timestamp');
+ expect(response.text).toContain('datetime');
+ expect(response.text).toContain('comment-id');
+ expect(response.text).toContain('voter-id');
+ expect(response.text).toContain('vote');
+
+ // Verify we have the expected number of votes
+ const voteLines = response.text.split('\n').filter((line) => line.trim().length > 0);
+ expect(voteLines.length - 1).toBe(testData.stats.totalVotes); // -1 for header row
+ });
+
+ test('GET /api/v3/reportExport/:report_id/unknown.csv - should handle unknown report type', async () => {
+ const response: Response = await textAgent.get(`/api/v3/reportExport/${reportId}/unknown.csv`);
+
+ expect(response.status).toBe(404);
+ expect(response.text).toContain('polis_error_data_unknown_report');
+ });
+
+ test('GET /api/v3/reportExport/nonexistent/comments.csv - should handle nonexistent report ID', async () => {
+ const response: Response = await textAgent.get('/api/v3/reportExport/nonexistent/comments.csv');
+
+ expect(response.status).toBe(400);
+ expect(response.text).toContain('polis_err_param_parse_failed_report_id');
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/domain-whitelist.test.ts b/server/__tests__/integration/domain-whitelist.test.ts
new file mode 100644
index 0000000000..96c82a5138
--- /dev/null
+++ b/server/__tests__/integration/domain-whitelist.test.ts
@@ -0,0 +1,101 @@
+import { beforeEach, describe, expect, test } from '@jest/globals';
+import { generateTestUser, newAgent, registerAndLoginUser } from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { AuthData } from '../../types/test-helpers';
+import { Agent } from 'supertest';
+
+interface DomainWhitelistResponse {
+ domain_whitelist: string;
+}
+
+describe('Domain Whitelist API', () => {
+ let agent: Agent;
+
+ // Setup with a registered and authenticated user
+ beforeEach(async () => {
+ const testUser = generateTestUser();
+ const auth: AuthData = await registerAndLoginUser(testUser);
+ agent = auth.agent;
+ });
+
+ test('GET /domainWhitelist - should retrieve domain whitelist settings for auth user', async () => {
+ const response: Response = await agent.get('/api/v3/domainWhitelist');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+
+ // Domain whitelist is returned as a list of domains or an empty string
+ expect(response.body).toHaveProperty('domain_whitelist');
+ expect((response.body as DomainWhitelistResponse).domain_whitelist).toEqual('');
+ });
+
+ test('GET /domainWhitelist - authentication behavior', async () => {
+ // Create an unauthenticated agent
+ const unauthAgent = await newAgent();
+
+ const response: Response = await unauthAgent.get('/api/v3/domainWhitelist');
+
+ expect(response.status).toBe(500);
+ expect(response.text).toMatch(/polis_err_auth_token_not_supplied/);
+ });
+
+ test('POST /domainWhitelist - should update domain whitelist settings', async () => {
+ const testDomains = 'example.com,test.org';
+
+ // Update whitelist
+ const updateResponse: Response = await agent.post('/api/v3/domainWhitelist').send({
+ domain_whitelist: testDomains
+ });
+
+ expect(updateResponse.status).toBe(200);
+
+ // Verify update was successful by getting the whitelist
+ const getResponse: Response = await agent.get('/api/v3/domainWhitelist');
+
+ expect(getResponse.status).toBe(200);
+ expect((getResponse.body as DomainWhitelistResponse)).toHaveProperty('domain_whitelist', testDomains);
+ });
+
+ test('POST /domainWhitelist - should accept empty domain whitelist', async () => {
+ // Update with empty whitelist
+ const updateResponse: Response = await agent.post('/api/v3/domainWhitelist').send({
+ domain_whitelist: ''
+ });
+
+ expect(updateResponse.status).toBe(200);
+
+ // Verify update
+ const getResponse: Response = await agent.get('/api/v3/domainWhitelist');
+
+ expect(getResponse.status).toBe(200);
+ expect((getResponse.body as DomainWhitelistResponse)).toHaveProperty('domain_whitelist', '');
+ });
+
+ // Note: The API doesn't validate domain format
+ // This test documents the current behavior rather than the expected behavior
+ test('POST /domainWhitelist - domain format validation behavior', async () => {
+ // Test with invalid domain format
+ const invalidResponse: Response = await agent.post('/api/v3/domainWhitelist').send({
+ domain_whitelist: 'invalid domain with spaces'
+ });
+
+ // Current behavior: The API accepts invalid domain formats
+ expect(invalidResponse.status).toBe(200);
+
+ const getResponse: Response = await agent.get('/api/v3/domainWhitelist');
+
+ expect(getResponse.status).toBe(200);
+ expect((getResponse.body as DomainWhitelistResponse)).toHaveProperty('domain_whitelist', 'invalid domain with spaces');
+ });
+
+ test('POST /domainWhitelist - authentication behavior', async () => {
+ const unauthAgent = await newAgent();
+
+ const response: Response = await unauthAgent.post('/api/v3/domainWhitelist').send({
+ domain_whitelist: 'example.com'
+ });
+
+ expect(response.status).toBe(500);
+ expect(response.text).toMatch(/polis_err_auth_token_not_supplied/);
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/example-global-agent.test.ts b/server/__tests__/integration/example-global-agent.test.ts
new file mode 100644
index 0000000000..3f388505e4
--- /dev/null
+++ b/server/__tests__/integration/example-global-agent.test.ts
@@ -0,0 +1,49 @@
+/**
+ * Example test demonstrating the use of global agents with the new pattern
+ */
+import { describe, expect, test } from '@jest/globals';
+import { authenticateAgent, getTestAgent, getTextAgent } from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import { Agent } from 'supertest';
+
+describe('Global Agent Example', () => {
+ test('Using getTestAgent for standard JSON responses', async () => {
+ // Get the agent using the async getter function
+ const agent = await getTestAgent();
+
+ // Make a request
+ const response: Response = await agent.get('/api/v3/testConnection');
+
+ // Verify response
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+ });
+
+ test('Using getTextAgent for text responses', async () => {
+ // Get the text agent using the async getter function
+ const textAgent = await getTextAgent();
+
+ // Make a request that might return text
+ const response: Response = await textAgent.post('/api/v3/auth/login').send({
+ // Intentionally missing required fields to get a text error
+ });
+
+ // Verify response
+ expect(response.status).toBe(400);
+ expect(response.text).toContain('polis_err_param_missing');
+ });
+
+ test('Authenticating an agent with a token', async () => {
+ // Get the agent using the async getter function
+ const agent = await getTestAgent();
+
+ // Example token (in a real test, you'd get this from a login response)
+ const mockToken = 'mock-token';
+
+ // Authenticate the agent
+ authenticateAgent(agent, mockToken);
+
+ // Verify the agent has the token set (this is just a demonstration)
+ expect(agent.get).toBeDefined();
+ });
+});
diff --git a/server/__tests__/integration/health.test.ts b/server/__tests__/integration/health.test.ts
new file mode 100644
index 0000000000..031d608bfe
--- /dev/null
+++ b/server/__tests__/integration/health.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test, beforeAll } from '@jest/globals';
+import { newAgent } from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import { Agent } from 'supertest';
+
+describe('Health Check Endpoints', () => {
+ // Create a dedicated agent for this test suite
+ let agent: Agent;
+
+ // Initialize agent before tests run
+ beforeAll(async () => {
+ // Initialize the agent asynchronously
+ agent = await newAgent();
+ console.log('Agent created, ready to run health tests.');
+ });
+
+ describe('GET /api/v3/testConnection', () => {
+ test('should return 200 OK', async () => {
+ const response: Response = await agent
+ .get('/api/v3/testConnection');
+
+ console.log('Response:', response.status, response.body);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+ expect(response.body.status).toBe('ok');
+ });
+ });
+
+ describe('GET /api/v3/testDatabase', () => {
+ test('should return 200 OK when database is connected', async () => {
+ const response: Response = await agent
+ .get('/api/v3/testDatabase');
+
+ console.log('Database Response:', response.status, response.body);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+ expect(response.body.status).toBe('ok');
+ });
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/invites.test.ts b/server/__tests__/integration/invites.test.ts
new file mode 100644
index 0000000000..d1405b2a6f
--- /dev/null
+++ b/server/__tests__/integration/invites.test.ts
@@ -0,0 +1,119 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import { createConversation, generateTestUser, registerAndLoginUser, newAgent } from '../setup/api-test-helpers';
+import { findEmailByRecipient } from '../setup/email-helpers';
+import type { EmailObject } from '../setup/email-helpers';
+import type { Response } from 'supertest';
+import type { AuthData, TestUser } from '../../types/test-helpers';
+
+describe('Email Invites API', () => {
+ let agent: ReturnType;
+ let conversationId: string;
+ let testUser: TestUser;
+
+ beforeAll(async () => {
+ // Register a user (conversation owner)
+ testUser = generateTestUser();
+ const auth: AuthData = await registerAndLoginUser(testUser);
+ agent = auth.agent;
+
+ // Create conversation
+ conversationId = await createConversation(agent);
+ });
+
+ test('POST /einvites - should create email invite and send welcome email', async () => {
+ const testEmail = `invite_${Date.now()}@example.com`;
+
+ // Use text agent for plain text response
+ const response: Response = await agent.post('/api/v3/einvites').send({
+ email: testEmail
+ });
+
+ // The response is empty
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({});
+
+ // Find and verify the welcome email
+ const email: EmailObject = await findEmailByRecipient(testEmail);
+ expect(email.to[0].address).toBe(testEmail);
+ expect(email.subject).toBe('Get Started with Polis');
+ expect(email.text).toContain('Welcome to pol.is!');
+ expect(email.text).toContain('/welcome/'); // Should contain the einvite link
+
+ // Extract the einvite code from the email
+ const einviteMatch = email.text.match(/\/welcome\/([a-zA-Z0-9]+)/);
+ expect(einviteMatch).toBeTruthy();
+ if (!einviteMatch) return; // TypeScript guard
+ const einvite = einviteMatch[1];
+ expect(einvite).toMatch(/^[a-zA-Z0-9]+$/); // Should be alphanumeric
+ });
+
+ test('POST /users/invite - should handle invitation emails with error validation', async () => {
+ // Clear any existing emails
+ // await deleteAllEmails();
+
+ // Use shorter email addresses to fit within VARCHAR(32)
+ const testEmails = [`inv1_${Date.now() % 1000}@ex.com`, `inv2_${Date.now() % 1000}@ex.com`];
+
+ const response: Response = await agent.post('/api/v3/users/invite').send({
+ conversation_id: conversationId,
+ emails: testEmails.join(',')
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ status: ':-)'
+ });
+
+ // Verify the invitation emails were sent
+ for (const email of testEmails) {
+ const sentEmail: EmailObject = await findEmailByRecipient(email);
+ expect(sentEmail).toBeTruthy();
+ expect(sentEmail.to[0].address).toBe(email);
+ expect(sentEmail.text).toContain(conversationId);
+ }
+ });
+
+ test('GET /verify - should handle email verification with error validation', async () => {
+ // This test will test the error cases since we can't generate a valid verification token
+
+ // Test missing 'e' parameter
+ const missingTokenResponse: Response = await agent.get('/api/v3/verify');
+
+ expect(missingTokenResponse.status).toBe(400);
+ expect(missingTokenResponse.text).toMatch(/polis_err_param_missing_e/);
+
+ // The invalid token case can cause server issues with headers already sent
+ // so we'll skip that test to avoid crashes
+ });
+
+ test('POST /sendCreatedLinkToEmail - should request email conversation link', async () => {
+ // Clear any existing emails
+ // await deleteAllEmails();
+
+ const response: Response = await agent.post('/api/v3/sendCreatedLinkToEmail').send({
+ conversation_id: conversationId
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({});
+
+ // Get the email that was sent
+ const email: EmailObject = await findEmailByRecipient(testUser.email);
+ expect(email).toBeTruthy();
+
+ // Verify email contents match the template from handle_POST_sendCreatedLinkToEmail
+ expect(email.to[0].address).toBe(testUser.email);
+ expect(email.text).toContain(`Hi ${testUser.hname}`);
+ expect(email.text).toContain("Here's a link to the conversation you just created");
+ expect(email.text).toContain(conversationId);
+ expect(email.text).toContain('With gratitude,\n\nThe team at pol.is');
+
+ // Verify the conversation link format
+ const linkMatch = email.text.match(/http:\/\/[^/]+\/#(\d+)\/([a-zA-Z0-9]+)/);
+ expect(linkMatch).toBeTruthy();
+ if (!linkMatch) return; // TypeScript guard
+ const [_, zid, zinvite] = linkMatch;
+ expect(zid).toBeTruthy();
+ expect(zinvite).toBeTruthy();
+ });
+});
diff --git a/server/__tests__/integration/math.test.ts b/server/__tests__/integration/math.test.ts
new file mode 100644
index 0000000000..75789ca66a
--- /dev/null
+++ b/server/__tests__/integration/math.test.ts
@@ -0,0 +1,196 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import {
+ createConversation,
+ getTestAgent,
+ populateConversationWithVotes,
+ setupAuthAndConvo
+} from '../setup/api-test-helpers';
+
+const NUM_PARTICIPANTS = 5;
+const NUM_COMMENTS = 5;
+
+interface PCAResponse {
+ pca: {
+ center: number[];
+ comps: number[][];
+ 'comment-extremity': number[];
+ 'comment-projection': number[][];
+ [key: string]: any;
+ };
+ consensus: any;
+ lastModTimestamp: number;
+ lastVoteTimestamp: number;
+ math_tick: number;
+ n: number;
+ repness: any;
+ tids: number[];
+ 'base-clusters': any;
+ 'comment-priorities': any;
+ 'group-aware-consensus': any;
+ 'group-clusters': any;
+ 'group-votes': any;
+ 'in-conv': any;
+ 'meta-tids': any;
+ 'mod-in': any;
+ 'mod-out': any;
+ 'n-cmts': number;
+ 'user-vote-counts': any;
+ 'votes-base': any;
+ [key: string]: any;
+}
+
+interface CorrelationResponse {
+ matrix?: number[][];
+ correlations?: any;
+ [key: string]: any;
+}
+
+describe('Math and Analysis Endpoints', () => {
+ let agent: Agent;
+ let conversationId: string | null = null;
+
+ beforeAll(async () => {
+ // Initialize the test agent
+ agent = await getTestAgent();
+
+ // Setup conversation with comments and votes to have data for analysis
+ const setup = await setupAuthAndConvo();
+ conversationId = setup.conversationId;
+
+ await populateConversationWithVotes({
+ conversationId,
+ numParticipants: NUM_PARTICIPANTS,
+ numComments: NUM_COMMENTS
+ });
+ });
+
+ test('GET /math/pca2 - Get Principal Component Analysis', async () => {
+ // Request PCA results for the conversation
+ // The response will be automatically decompressed by our supertest agent
+ const { body, status } = await agent.get(`/api/v3/math/pca2?conversation_id=${conversationId}`);
+
+ // Validate response
+ expect(status).toBe(200);
+ expect(body).toBeDefined();
+
+ // The response has been decompressed and parsed from gzip
+ if (body) {
+ const pcaResponse = body as PCAResponse;
+ expect(pcaResponse.pca).toBeDefined();
+ const { pca } = pcaResponse;
+
+ // Check that the body has the expected fields
+ expect(pcaResponse.consensus).toBeDefined();
+ expect(pcaResponse.lastModTimestamp).toBeDefined();
+ expect(pcaResponse.lastVoteTimestamp).toBeDefined();
+ expect(pcaResponse.math_tick).toBeDefined();
+ expect(pcaResponse.n).toBeDefined();
+ expect(pcaResponse.repness).toBeDefined();
+ expect(pcaResponse.tids).toBeDefined();
+ expect(pcaResponse['base-clusters']).toBeDefined();
+ expect(pcaResponse['comment-priorities']).toBeDefined();
+ expect(pcaResponse['group-aware-consensus']).toBeDefined();
+ expect(pcaResponse['group-clusters']).toBeDefined();
+ expect(pcaResponse['group-votes']).toBeDefined();
+ expect(pcaResponse['in-conv']).toBeDefined();
+ expect(pcaResponse['meta-tids']).toBeDefined();
+ expect(pcaResponse['mod-in']).toBeDefined();
+ expect(pcaResponse['mod-out']).toBeDefined();
+ expect(pcaResponse['n-cmts']).toBeDefined();
+ expect(pcaResponse['user-vote-counts']).toBeDefined();
+ expect(pcaResponse['votes-base']).toBeDefined();
+
+ // Check that the PCA results are defined
+ expect(pca.center).toBeDefined();
+ expect(pca.comps).toBeDefined();
+ expect(pca['comment-extremity']).toBeDefined();
+ expect(pca['comment-projection']).toBeDefined();
+ }
+ });
+
+ // Requires Report ID to exist first.
+ // TODO: Revisit this after Reports have been covered in tests.
+ test.skip('GET /api/v3/math/correlationMatrix - Get correlation matrix', async () => {
+ // Request correlation matrix for the conversation
+ const response: Response = await agent.get(`/api/v3/math/correlationMatrix?conversation_id=${conversationId}`);
+
+ // Validate response
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+
+ // Correlation matrix should be an array or object with correlation data
+ if (response.body) {
+ const correlationResponse = response.body as CorrelationResponse;
+
+ // Check for structure - could be:
+ // 1. A 2D array/matrix
+ // 2. An object with correlation data
+ // 3. An object with a matrix property
+
+ const hasCorrelationData = Array.isArray(correlationResponse) || correlationResponse.matrix || correlationResponse.correlations;
+
+ expect(hasCorrelationData).toBeTruthy();
+ }
+ });
+
+ test('Math endpoints - Return 400 for missing conversation_id', async () => {
+ // Request PCA without conversation_id
+ const pcaResponse: Response = await agent.get('/api/v3/math/pca2');
+
+ expect(pcaResponse.status).toBe(400);
+ expect(pcaResponse.text).toMatch(/polis_err_param_missing_conversation_id/);
+
+ // Request correlation matrix without report_id
+ const corrResponse: Response = await agent.get(`/api/v3/math/correlationMatrix?conversation_id=${conversationId}`);
+
+ expect(corrResponse.status).toBe(400);
+ expect(corrResponse.text).toMatch(/polis_err_param_missing_report_id/);
+ });
+
+ test('Math endpoints - Return appropriate error for invalid conversation_id', async () => {
+ const invalidId = 'nonexistent-conversation-id';
+
+ // Request PCA with invalid conversation_id
+ const pcaResponse: Response = await agent.get(`/api/v3/math/pca2?conversation_id=${invalidId}`);
+
+ // Should return an error status
+ expect(pcaResponse.status).toBeGreaterThanOrEqual(400);
+ expect(pcaResponse.text).toMatch(/polis_err_param_parse_failed_conversation_id/);
+ expect(pcaResponse.text).toMatch(/polis_err_fetching_zid_for_conversation_id/);
+
+ // Request correlation matrix with invalid report_id
+ const corrResponse: Response = await agent.get(`/api/v3/math/correlationMatrix?report_id=${invalidId}`);
+
+ // Should return an error status
+ expect(corrResponse.status).toBeGreaterThanOrEqual(400);
+ expect(corrResponse.text).toMatch(/polis_err_param_parse_failed_report_id/);
+ expect(corrResponse.text).toMatch(/polis_err_fetching_rid_for_report_id/);
+ });
+
+ test('Math endpoints - Require sufficient data for meaningful analysis', async () => {
+ // Create a new empty conversation
+ const emptyConvoId = await createConversation(agent);
+
+ // Request PCA for empty conversation
+ const { body, status } = await agent.get(`/api/v3/math/pca2?conversation_id=${emptyConvoId}`);
+
+ expect(status).toBe(304);
+ expect(body).toBe('');
+
+ // TODO: Request correlation matrix for empty conversation
+ });
+
+ test('Math endpoints - Support math_tick parameter', async () => {
+ // Request PCA with math_tick parameter
+ const pcaResponse: Response = await agent.get(`/api/v3/math/pca2?conversation_id=${conversationId}&math_tick=2`);
+
+ // Validate response
+ expect(pcaResponse.status).toBe(200);
+
+ // TODO: Check that the math_tick is respected
+
+ // TODO: Request correlation matrix with math_tick parameter
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/next-comment.test.ts b/server/__tests__/integration/next-comment.test.ts
new file mode 100644
index 0000000000..5b2f550a96
--- /dev/null
+++ b/server/__tests__/integration/next-comment.test.ts
@@ -0,0 +1,151 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import {
+ getTestAgent,
+ getTextAgent,
+ initializeParticipant,
+ setupAuthAndConvo,
+ submitVote
+} from '../setup/api-test-helpers';
+
+interface Comment {
+ tid: number;
+ txt: string;
+ [key: string]: any;
+}
+
+describe('Next Comment Endpoint', () => {
+ // Declare agent variables
+ let agent: Agent;
+ let textAgent: Agent;
+ let conversationId: string | null = null;
+ let commentIds: number[] = [];
+
+ beforeAll(async () => {
+ // Initialize agents
+ agent = await getTestAgent();
+ textAgent = await getTextAgent();
+
+ // Setup auth and create test conversation with multiple comments
+ const setup = await setupAuthAndConvo({
+ commentCount: 5
+ });
+
+ conversationId = setup.conversationId;
+ commentIds = setup.commentIds;
+
+ // Ensure we have comments to work with
+ expect(commentIds.length).toBe(5);
+ });
+
+ test('GET /nextComment - Get next comment for voting', async () => {
+ // Request the next comment for voting
+ const response: Response = await agent.get(`/api/v3/nextComment?conversation_id=${conversationId}`);
+
+ // Validate response
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+
+ // The response should have a tid (comment ID) and txt (comment text)
+ expect(response.body.tid).toBeDefined();
+ expect(response.body.txt).toBeDefined();
+
+ // The returned comment should be one of our test comments
+ expect(commentIds).toContain(response.body.tid);
+ });
+
+ test('GET /nextComment - Anonymous users can get next comment', async () => {
+ // Initialize anonymous participant
+ const { agent: anonAgent } = await initializeParticipant(conversationId!);
+
+ // Request next comment as anonymous user
+ const response: Response = await anonAgent.get(`/api/v3/nextComment?conversation_id=${conversationId}`);
+
+ // Validate response
+ expect(response.status).toBe(200);
+ expect(response.body).toBeDefined();
+ expect(response.body.tid).toBeDefined();
+ expect(response.body.txt).toBeDefined();
+ });
+
+ test('GET /nextComment - Respect not_voted_by_pid parameter', async () => {
+ // Initialize a new participant
+ const { agent: firstAgent, body: initBody } = await initializeParticipant(conversationId!);
+ expect(initBody.nextComment).toBeDefined();
+ const { nextComment: firstComment } = initBody;
+
+ // Submit vote to get auth token
+ const firstVoteResponse = await submitVote(firstAgent, {
+ tid: firstComment.tid,
+ conversation_id: conversationId!,
+ vote: 0
+ });
+
+ expect(firstVoteResponse.status).toBe(200);
+ expect(firstVoteResponse.body).toHaveProperty('currentPid');
+ expect(firstVoteResponse.body).toHaveProperty('nextComment');
+
+ const { currentPid: firstVoterPid, nextComment: secondComment } = firstVoteResponse.body;
+
+ // Vote on 3 more comments
+ const secondVoteResponse = await submitVote(firstAgent, {
+ pid: firstVoterPid,
+ tid: secondComment.tid,
+ conversation_id: conversationId!,
+ vote: 0
+ });
+
+ const thirdVoteResponse = await submitVote(firstAgent, {
+ pid: firstVoterPid,
+ tid: secondVoteResponse.body.nextComment.tid,
+ conversation_id: conversationId!,
+ vote: 0
+ });
+
+ const fourthVoteResponse = await submitVote(firstAgent, {
+ pid: firstVoterPid,
+ tid: thirdVoteResponse.body.nextComment.tid,
+ conversation_id: conversationId!,
+ vote: 0
+ });
+
+ const lastComment = fourthVoteResponse.body.nextComment;
+
+ // Initialize a new participant
+ const { agent: secondAgent } = await initializeParticipant(conversationId!);
+
+ // Get next comment
+ const nextResponse: Response = await secondAgent.get(
+ `/api/v3/nextComment?conversation_id=${conversationId}¬_voted_by_pid=${firstVoterPid}`
+ );
+
+ // Validate response - should return the comment not voted on by the first participant
+ expect(nextResponse.status).toBe(200);
+ expect(nextResponse.body).toBeDefined();
+ expect(nextResponse.body.tid).toBe(lastComment.tid);
+ });
+
+ test('GET /nextComment - 400 for missing conversation_id', async () => {
+ // Request without required conversation_id
+ const response: Response = await textAgent.get('/api/v3/nextComment');
+
+ // Validate response
+ expect(response.status).toBe(400);
+ expect(response.text).toMatch(/polis_err_param_missing_conversation_id/);
+ });
+
+ test('GET /nextComment - Handles `without` parameter', async () => {
+ const withoutCommentIds = commentIds.slice(0, 4);
+
+ // Request next comment without comments 0-3
+ const response: Response = await agent.get(
+ `/api/v3/nextComment?conversation_id=${conversationId}&without=${withoutCommentIds}`
+ );
+
+ // Validate response is the last comment
+ expect(response.status).toBe(200);
+ expect(response.body.tid).toBe(commentIds[4]);
+ expect(withoutCommentIds).not.toContain(response.body.tid);
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/notifications.test.ts b/server/__tests__/integration/notifications.test.ts
new file mode 100644
index 0000000000..c1428b744f
--- /dev/null
+++ b/server/__tests__/integration/notifications.test.ts
@@ -0,0 +1,123 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import {
+ createConversation,
+ createHmacSignature,
+ generateTestUser,
+ newTextAgent,
+ registerAndLoginUser
+} from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import type { AuthData, TestUser } from '../../types/test-helpers';
+
+interface SubscriptionResponse {
+ subscribed: number;
+ [key: string]: any;
+}
+
+describe('Notification Subscription API', () => {
+ let conversationId: string;
+ let agent: Agent;
+ let textAgent: Agent;
+ let testUser: TestUser;
+
+ beforeAll(async () => {
+ // Create an authenticated user and conversation
+ testUser = generateTestUser();
+ const auth: AuthData = await registerAndLoginUser(testUser);
+ agent = auth.agent;
+ textAgent = auth.textAgent;
+
+ // Create a conversation for testing
+ conversationId = await createConversation(agent);
+ });
+
+ test('GET /notifications/subscribe - should handle signature validation', async () => {
+ const email = testUser.email;
+ const signature = createHmacSignature(email, conversationId);
+
+ // Using textAgent to handle text response properly
+ const response: Response = await textAgent.get('/api/v3/notifications/subscribe').query({
+ signature,
+ conversation_id: conversationId,
+ email
+ });
+
+ // We now expect success since we're using the correct HMAC generation
+ expect(response.status).toBe(200);
+ expect(response.text).toContain('Subscribed!');
+ });
+
+ test('GET /notifications/unsubscribe - should handle signature validation', async () => {
+ const email = testUser.email;
+ const signature = createHmacSignature(email, conversationId, 'api/v3/notifications/unsubscribe');
+
+ // Using textAgent to handle text response properly
+ const response: Response = await textAgent.get('/api/v3/notifications/unsubscribe').query({
+ signature,
+ conversation_id: conversationId,
+ email
+ });
+
+ // We now expect success since we're using the correct path and key
+ expect(response.status).toBe(200);
+ expect(response.text).toContain('Unsubscribed');
+ });
+
+ test('POST /convSubscriptions - should allow subscribing to conversation updates', async () => {
+ const response: Response = await agent.post('/api/v3/convSubscriptions').send({
+ conversation_id: conversationId,
+ email: testUser.email,
+ type: 1 // Subscription type (1 = updates)
+ });
+
+ expect(response.status).toBe(200);
+
+ // Subscription confirmation should be returned
+ expect(response.body).toEqual({ subscribed: 1 });
+ });
+
+ test('POST /convSubscriptions - authentication behavior (currently not enforced)', async () => {
+ // Create unauthenticated agent
+ const unauthAgent = await newTextAgent();
+
+ const response: Response = await unauthAgent.post('/api/v3/convSubscriptions').send({
+ conversation_id: conversationId,
+ email: testUser.email,
+ type: 1
+ });
+
+ // The API gives a 500 error when the user is not authenticated
+ expect(response.status).toBe(500);
+ expect(response.text).toMatch(/polis_err_auth_token_not_supplied/);
+ });
+
+ test('POST /convSubscriptions - should validate required parameters', async () => {
+ // Test missing email
+ const missingEmailResponse: Response = await agent.post('/api/v3/convSubscriptions').send({
+ conversation_id: conversationId,
+ type: 1
+ });
+
+ expect(missingEmailResponse.status).toBe(400);
+ expect(missingEmailResponse.text).toMatch(/polis_err_param_missing_email/);
+
+ // Test missing conversation_id
+ const missingConvoResponse: Response = await agent.post('/api/v3/convSubscriptions').send({
+ email: testUser.email,
+ type: 1
+ });
+
+ expect(missingConvoResponse.status).toBe(400);
+ expect(missingConvoResponse.text).toMatch(/polis_err_param_missing_conversation_id/);
+
+ // Test missing type
+ const missingTypeResponse: Response = await agent.post('/api/v3/convSubscriptions').send({
+ conversation_id: conversationId,
+ email: testUser.email
+ });
+
+ expect(missingTypeResponse.status).toBe(400);
+ expect(missingTypeResponse.text).toMatch(/polis_err_param_missing_type/);
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/participant-metadata.test.ts b/server/__tests__/integration/participant-metadata.test.ts
new file mode 100644
index 0000000000..0024fd38e0
--- /dev/null
+++ b/server/__tests__/integration/participant-metadata.test.ts
@@ -0,0 +1,284 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import {
+ createComment,
+ createConversation,
+ getTextAgent,
+ initializeParticipant,
+ registerAndLoginUser,
+ submitVote
+} from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import type { AuthData } from '../../types/test-helpers';
+
+interface MetadataQuestion {
+ pmqid: number;
+ key: string;
+ [key: string]: any;
+}
+
+interface MetadataAnswer {
+ pmaid: number;
+ pmqid: number;
+ value: string;
+ [key: string]: any;
+}
+
+interface MetadataResponse {
+ keys: Record;
+ values: Record;
+ kvp: Record;
+}
+
+describe('Participant Metadata API', () => {
+ let agent: Agent;
+ let textAgent: Agent;
+ let conversationId: string;
+ let participantAgent: Agent;
+
+ beforeAll(async () => {
+ // Register a user (conversation owner)
+ const auth: AuthData = await registerAndLoginUser();
+ agent = auth.agent;
+ textAgent = await getTextAgent(); // Create a text agent for text responses
+
+ // Create conversation
+ conversationId = await createConversation(agent);
+
+ // Initialize a participant
+ const { agent: pAgent } = await initializeParticipant(conversationId);
+ participantAgent = pAgent;
+
+ // Create a comment to establish a real participant (needed for choices test)
+ const commentId = await createComment(participantAgent, conversationId, {
+ conversation_id: conversationId,
+ txt: 'Test comment for metadata'
+ });
+
+ // Submit a vote to establish a real pid
+ await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: 1
+ });
+ });
+
+ test('POST /api/v3/metadata/questions - should create metadata question', async () => {
+ const questionKey = `test_question_${Date.now()}`;
+ const response: Response = await agent.post('/api/v3/metadata/questions').send({
+ conversation_id: conversationId,
+ key: questionKey
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveProperty('pmqid');
+
+ // Verify the question was created by fetching it
+ const getResponse: Response = await agent.get(`/api/v3/metadata/questions?conversation_id=${conversationId}`);
+ const createdQuestion = (getResponse.body as MetadataQuestion[]).find(q => q.key === questionKey);
+ expect(createdQuestion).toBeDefined();
+ expect(createdQuestion!.pmqid).toBe(response.body.pmqid);
+ });
+
+ test('GET /api/v3/metadata/questions - should list metadata questions', async () => {
+ // Create a question first to ensure there's data
+ const questionKey = `test_question_${Date.now()}`;
+ await agent.post('/api/v3/metadata/questions').send({
+ conversation_id: conversationId,
+ key: questionKey
+ });
+
+ const response: Response = await agent.get(`/api/v3/metadata/questions?conversation_id=${conversationId}`);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBeGreaterThan(0);
+
+ // Check structure of the first question
+ expect(response.body[0]).toHaveProperty('pmqid');
+ expect(response.body[0]).toHaveProperty('key');
+ });
+
+ describe('with existing question', () => {
+ let pmqid: number;
+
+ beforeAll(async () => {
+ // Create a question for these tests
+ const response: Response = await agent.post('/api/v3/metadata/questions').send({
+ conversation_id: conversationId,
+ key: `test_question_${Date.now()}`
+ });
+ pmqid = response.body.pmqid;
+ });
+
+ test('POST /api/v3/metadata/answers - should create metadata answer', async () => {
+ const answerValue = `test_answer_${Date.now()}`;
+ const response: Response = await agent.post('/api/v3/metadata/answers').send({
+ conversation_id: conversationId,
+ pmqid: pmqid,
+ value: answerValue
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveProperty('pmaid');
+
+ // Verify the answer was created
+ const getResponse: Response = await agent.get(`/api/v3/metadata/answers?conversation_id=${conversationId}`);
+ const createdAnswer = (getResponse.body as MetadataAnswer[]).find(
+ a => a.pmqid === pmqid && a.value === answerValue
+ );
+ expect(createdAnswer).toBeDefined();
+ expect(createdAnswer!.pmaid).toBe(response.body.pmaid);
+ });
+
+ test('GET /api/v3/metadata/answers - should list metadata answers', async () => {
+ // Create an answer first to ensure there's data
+ const answerValue = `test_answer_${Date.now()}`;
+ await agent.post('/api/v3/metadata/answers').send({
+ conversation_id: conversationId,
+ pmqid: pmqid,
+ value: answerValue
+ });
+
+ const response: Response = await agent.get(`/api/v3/metadata/answers?conversation_id=${conversationId}`);
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body.length).toBeGreaterThan(0);
+
+ // Check structure of the first answer
+ expect(response.body[0]).toHaveProperty('pmaid');
+ expect(response.body[0]).toHaveProperty('pmqid');
+ expect(response.body[0]).toHaveProperty('value');
+ });
+
+ describe('with existing answer', () => {
+ let pmaid: number;
+
+ beforeAll(async () => {
+ // Create an answer for these tests
+ const response: Response = await agent.post('/api/v3/metadata/answers').send({
+ conversation_id: conversationId,
+ pmqid: pmqid,
+ value: `test_answer_${Date.now()}`
+ });
+ pmaid = response.body.pmaid;
+ });
+
+ test('GET /api/v3/metadata - should retrieve all metadata', async () => {
+ const response: Response = await agent.get(`/api/v3/metadata?conversation_id=${conversationId}`);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveProperty('keys');
+ expect(response.body).toHaveProperty('values');
+ expect(response.body).toHaveProperty('kvp');
+
+ const metadata = response.body as MetadataResponse;
+ expect(typeof metadata.keys).toBe('object');
+ expect(typeof metadata.values).toBe('object');
+ });
+
+ test('POST /api/v3/query_participants_by_metadata - query participants by metadata', async () => {
+ const queryResponse: Response = await agent.post('/api/v3/query_participants_by_metadata').send({
+ conversation_id: conversationId,
+ pmaids: [pmaid]
+ });
+
+ expect(queryResponse.status).toBe(200);
+ expect(queryResponse.body).toBeDefined();
+ expect(Array.isArray(queryResponse.body)).toBe(true);
+ });
+ });
+ });
+
+ test('DELETE /api/v3/metadata/questions/:pmqid - should delete a metadata question', async () => {
+ // Create a question to delete
+ const createResponse: Response = await agent.post('/api/v3/metadata/questions').send({
+ conversation_id: conversationId,
+ key: 'question_to_delete'
+ });
+
+ expect(createResponse.status).toBe(200);
+ const deleteId = createResponse.body.pmqid;
+
+ // Use the text agent for text responses
+ const deleteResponse: Response = await textAgent.delete(`/api/v3/metadata/questions/${deleteId}`);
+
+ // The API returns "OK" as text
+ expect(deleteResponse.status).toBe(200);
+ expect(deleteResponse.text).toBe('OK');
+
+ // Verify it was deleted (or marked as not alive)
+ const getResponse: Response = await agent.get(`/api/v3/metadata/questions?conversation_id=${conversationId}`);
+ const deletedQuestion = (getResponse.body as MetadataQuestion[]).find(q => q.pmqid === deleteId);
+ expect(deletedQuestion).toBeUndefined();
+ });
+
+ test('DELETE /api/v3/metadata/answers/:pmaid - should delete a metadata answer', async () => {
+ // Create a question first
+ const questionResponse: Response = await agent.post('/api/v3/metadata/questions').send({
+ conversation_id: conversationId,
+ key: `test_question_${Date.now()}`
+ });
+ const pmqid = questionResponse.body.pmqid;
+
+ // Add an answer to delete
+ const createResponse: Response = await agent.post('/api/v3/metadata/answers').send({
+ conversation_id: conversationId,
+ pmqid: pmqid,
+ value: 'answer_to_delete'
+ });
+
+ expect(createResponse.status).toBe(200);
+ const deleteId = createResponse.body.pmaid;
+
+ // Use the text agent for text responses
+ const deleteResponse: Response = await textAgent.delete(`/api/v3/metadata/answers/${deleteId}`);
+
+ // The API returns "OK" as text
+ expect(deleteResponse.status).toBe(200);
+ expect(deleteResponse.text).toBe('OK');
+
+ // Verify it was deleted (or marked as not alive)
+ const getResponse: Response = await agent.get(`/api/v3/metadata/answers?conversation_id=${conversationId}`);
+ const deletedAnswer = (getResponse.body as MetadataAnswer[]).find(a => a.pmaid === deleteId);
+ expect(deletedAnswer).toBeUndefined();
+ });
+
+ test('PUT /api/v3/participants_extended - should work for conversation owner', async () => {
+ // Test with the owner agent
+ const ownerResponse: Response = await agent.put('/api/v3/participants_extended').send({
+ conversation_id: conversationId,
+ show_translation_activated: true
+ });
+
+ // The owner should be able to update their own settings
+ expect(ownerResponse.status).toBe(200);
+ });
+
+ test('PUT /api/v3/participants_extended - handles participant access correctly', async () => {
+ // Test with the participant agent
+ const participantResponse: Response = await participantAgent.put('/api/v3/participants_extended').send({
+ conversation_id: conversationId,
+ show_translation_activated: false
+ });
+
+ // The API might return 200 (if the participant has a proper pid)
+ // or might return a 500 error with auth error (if pid resolution fails)
+ if (participantResponse.status === 200) {
+ expect(participantResponse.status).toBe(200);
+ } else {
+ expect(participantResponse.status).toBe(500);
+ }
+ });
+
+ test('GET /api/v3/metadata/choices - should retrieve metadata choices', async () => {
+ const response: Response = await agent.get(`/api/v3/metadata/choices?conversation_id=${conversationId}`);
+
+ expect(response.status).toBe(200);
+
+ // Depending on whether choices have been made, this might be empty
+ // but the endpoint should always return a valid response
+ expect(Array.isArray(response.body)).toBe(true);
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/participation.test.ts b/server/__tests__/integration/participation.test.ts
new file mode 100644
index 0000000000..98b6299024
--- /dev/null
+++ b/server/__tests__/integration/participation.test.ts
@@ -0,0 +1,83 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import {
+ generateRandomXid,
+ getTestAgent,
+ initializeParticipant,
+ initializeParticipantWithXid,
+ setupAuthAndConvo
+} from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import { Agent } from 'supertest';
+
+interface ParticipationResponse {
+ agent: Agent;
+ body: any;
+ cookies: string[] | string | undefined;
+ status: number;
+}
+
+describe('Participation Endpoints', () => {
+ // Declare agent variable
+ let agent: Agent;
+ const testXid = generateRandomXid();
+ let conversationId: string;
+
+ beforeAll(async () => {
+ // Initialize agent
+ agent = await getTestAgent();
+
+ // Setup auth and create test conversation with comments
+ const setup = await setupAuthAndConvo({
+ commentCount: 3
+ });
+
+ conversationId = setup.conversationId;
+ });
+
+ test('Regular participation lifecycle', async () => {
+ // STEP 1: Initialize anonymous participant
+ const { agent: anonAgent, body, cookies, status }: ParticipationResponse = await initializeParticipant(conversationId);
+
+ expect(status).toBe(200);
+ expect(cookies).toBeDefined();
+ expect(cookies).toBeTruthy();
+ expect(body).toBeDefined();
+
+ // STEP 2: Get next comment for participant
+ const nextCommentResponse: Response = await anonAgent.get(`/api/v3/nextComment?conversation_id=${conversationId}`);
+
+ expect(nextCommentResponse.status).toBe(200);
+ expect(JSON.parse(nextCommentResponse.text)).toBeDefined();
+ });
+
+ test('XID participation lifecycle', async () => {
+ // STEP 1: Initialize participation with XID
+ const { agent: xidAgent, body, cookies, status }: ParticipationResponse = await initializeParticipantWithXid(conversationId, testXid);
+
+ expect(status).toBe(200);
+ expect(cookies).toBeDefined();
+ expect(cookies).toBeTruthy();
+ expect(body).toBeDefined();
+
+ // STEP 2: Get next comment for participant
+ const nextCommentResponse: Response = await xidAgent.get(
+ `/api/v3/nextComment?conversation_id=${conversationId}&xid=${testXid}`
+ );
+
+ expect(nextCommentResponse.status).toBe(200);
+ expect(JSON.parse(nextCommentResponse.text)).toBeDefined();
+ });
+
+ test('Participation validation', async () => {
+ // Test missing conversation ID in participation
+ const missingConvResponse: Response = await agent.get('/api/v3/participation');
+ expect(missingConvResponse.status).toBe(400);
+
+ // Test missing conversation ID in participationInit
+ const missingConvInitResponse: Response = await agent.get('/api/v3/participationInit');
+ expect(missingConvInitResponse.status).toBe(200);
+ const responseBody = JSON.parse(missingConvInitResponse.text);
+ expect(responseBody).toBeDefined();
+ expect(responseBody.conversation).toBeNull();
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/password-reset.test.ts b/server/__tests__/integration/password-reset.test.ts
new file mode 100644
index 0000000000..ed4a60c861
--- /dev/null
+++ b/server/__tests__/integration/password-reset.test.ts
@@ -0,0 +1,134 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import { generateTestUser, getTestAgent, getTextAgent, registerAndLoginUser } from '../setup/api-test-helpers';
+import { getPasswordResetUrl } from '../setup/email-helpers';
+import type { Response } from 'supertest';
+import type { TestUser } from '../../types/test-helpers';
+import { Agent } from 'supertest';
+
+describe('Password Reset API', () => {
+ // Declare agent variables
+ let agent: Agent;
+ let textAgent: Agent;
+ let testUser: TestUser;
+
+ // Setup - create a test user for password reset tests and clear mailbox
+ beforeAll(async () => {
+ // Initialize agents
+ agent = await getTestAgent();
+ textAgent = await getTextAgent();
+ testUser = generateTestUser();
+
+ // Register the user
+ await registerAndLoginUser(testUser);
+ });
+
+ describe('POST /auth/pwresettoken', () => {
+ test('should generate a password reset token for a valid email', async () => {
+ const response: Response = await textAgent.post('/api/v3/auth/pwresettoken').send({
+ email: testUser.email
+ });
+
+ // Check successful response
+ expect(response.status).toBe(200);
+ expect(response.text).toMatch(/Password reset email sent, please check your email./);
+ });
+
+ // Existence of an email address in the system should not be inferable from the response
+ test('should behave normally for non-existent email', async () => {
+ const nonExistentEmail = `nonexistent-${testUser.email}`;
+
+ const response: Response = await textAgent.post('/api/v3/auth/pwresettoken').send({
+ email: nonExistentEmail
+ });
+
+ // The API should return success even for non-existent emails
+ expect(response.status).toBe(200);
+ expect(response.text).toMatch(/Password reset email sent, please check your email./);
+ });
+
+ test('should return an error for missing email parameter', async () => {
+ const response: Response = await textAgent.post('/api/v3/auth/pwresettoken').send({});
+
+ expect(response.status).toBe(400);
+ expect(response.text).toMatch(/polis_err_param_missing_email/);
+ });
+ });
+
+ describe('Password Reset Flow', () => {
+ const newPassword = 'NewTestPassword123!';
+
+ test('should request a reset token, reset password, and login with new password', async () => {
+ // Step 1: Request reset token
+ const tokenResponse: Response = await agent.post('/api/v3/auth/pwresettoken').send({
+ email: testUser.email
+ });
+
+ expect(tokenResponse.status).toBe(200);
+
+ // Get the reset URL from the email
+ const resetResult = await getPasswordResetUrl(testUser.email);
+
+ expect(resetResult.url).toBeTruthy();
+ expect(resetResult.token).toBeTruthy();
+ const pwResetUrl = resetResult.url as string;
+ const resetToken = resetResult.token as string;
+
+ // Step 2: GET the reset page with token
+ const url = new URL(pwResetUrl);
+ const resetPageResponse: Response = await agent.get(url.pathname);
+ expect(resetPageResponse.status).toBe(200);
+
+ // Step 3: Submit the reset with new password
+ const resetResponse: Response = await agent.post('/api/v3/auth/password').send({
+ newPassword: newPassword,
+ pwresettoken: resetToken
+ });
+ expect(resetResponse.status).toBe(200);
+
+ // Step 4: Verify we can login with the new password
+ const loginResponse: Response = await agent.post('/api/v3/auth/login').send({
+ email: testUser.email,
+ password: newPassword
+ });
+
+ expect(loginResponse.status).toBe(200);
+ const cookies = loginResponse.headers['set-cookie'];
+ expect(cookies).toBeTruthy();
+ expect(Array.isArray(cookies)).toBe(true);
+ const cookiesArray = (cookies as unknown) as string[];
+ expect(cookiesArray.some((cookie) => cookie.startsWith('token2='))).toBe(true);
+ expect(cookiesArray.some((cookie) => cookie.startsWith('uid2='))).toBe(true);
+ });
+
+ test('should reject reset attempts with invalid tokens', async () => {
+ const invalidToken = `invalid_token_${Date.now()}`;
+
+ const resetResponse: Response = await textAgent.post('/api/v3/auth/password').send({
+ newPassword: 'AnotherPassword123!',
+ pwresettoken: invalidToken
+ });
+
+ // Should be an error response
+ expect(resetResponse.status).toBe(500);
+ expect(resetResponse.text).toMatch(/Password Reset failed. Couldn't find matching pwresettoken./);
+ });
+
+ test('should reject reset attempts with missing parameters', async () => {
+ // Missing token
+ const resetResponse1: Response = await textAgent.post('/api/v3/auth/password').send({
+ newPassword: 'AnotherPassword123!'
+ });
+
+ expect(resetResponse1.status).toBe(400);
+ expect(resetResponse1.text).toMatch(/polis_err_param_missing_pwresettoken/);
+
+ // Missing password
+ const resetResponse2: Response = await textAgent.post('/api/v3/auth/password').send({
+ pwresettoken: 'some_token'
+ });
+
+ expect(resetResponse2.status).toBe(400);
+ expect(resetResponse2.text).toMatch(/polis_err_param_missing_newPassword/);
+ });
+ });
+});
diff --git a/server/__tests__/integration/reports.test.ts b/server/__tests__/integration/reports.test.ts
new file mode 100644
index 0000000000..fa67ce7828
--- /dev/null
+++ b/server/__tests__/integration/reports.test.ts
@@ -0,0 +1,151 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import { createConversation, getTextAgent, registerAndLoginUser } from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { Agent } from 'supertest';
+import type { AuthData } from '../../types/test-helpers';
+
+interface Report {
+ report_id: string;
+ conversation_id: string;
+ report_name?: string;
+ label_x_pos?: string;
+ label_x_neg?: string;
+ label_y_pos?: string;
+ label_y_neg?: string;
+ label_group_0?: string;
+ label_group_1?: string;
+ [key: string]: any;
+}
+
+describe('Reports API', () => {
+ let agent: Agent;
+ let textAgent: Agent;
+ let conversationId: string;
+
+ beforeAll(async () => {
+ // Register a user (conversation owner)
+ const auth: AuthData = await registerAndLoginUser();
+ agent = auth.agent;
+ textAgent = await getTextAgent();
+
+ // Create a conversation
+ conversationId = await createConversation(agent);
+ });
+
+ test('POST /api/v3/reports - should create a new report', async () => {
+ const response: Response = await textAgent.post('/api/v3/reports').send({
+ conversation_id: conversationId
+ });
+
+ // Should return successful response
+ expect(response.status).toBe(200);
+ expect(response.text).toBe('{}');
+
+ // Verify report was created by checking conversation reports
+ const getResponse: Response = await textAgent.get(`/api/v3/reports?conversation_id=${conversationId}`);
+ const reports: Report[] = JSON.parse(getResponse.text);
+ expect(Array.isArray(reports)).toBe(true);
+ expect(reports.length).toBeGreaterThan(0);
+ expect(reports[0]).toHaveProperty('conversation_id', conversationId);
+ });
+
+ test('GET /api/v3/reports - should return reports for the conversation', async () => {
+ // First create a report to ensure there's something to fetch
+ await textAgent.post('/api/v3/reports').send({
+ conversation_id: conversationId
+ });
+
+ const response: Response = await textAgent.get(`/api/v3/reports?conversation_id=${conversationId}`);
+
+ // Should return successful response
+ expect(response.status).toBe(200);
+
+ // Response should contain at least one report
+ const reports: Report[] = JSON.parse(response.text);
+ expect(Array.isArray(reports)).toBe(true);
+ expect(reports.length).toBeGreaterThan(0);
+
+ // Each report should have conversation_id field
+ expect(reports[0]).toHaveProperty('conversation_id', conversationId);
+ });
+
+ describe('with existing report', () => {
+ let reportId: string;
+
+ beforeAll(async () => {
+ // Create a report for these tests
+ await textAgent.post('/api/v3/reports').send({
+ conversation_id: conversationId
+ });
+
+ // Get the report ID
+ const response: Response = await textAgent.get(`/api/v3/reports?conversation_id=${conversationId}`);
+ const reports: Report[] = JSON.parse(response.text);
+ reportId = reports[0].report_id;
+ });
+
+ test('PUT /api/v3/reports - should update report details', async () => {
+ const testReportName = 'Test Report Name';
+
+ const response: Response = await textAgent.put('/api/v3/reports').send({
+ conversation_id: conversationId,
+ report_id: reportId,
+ report_name: testReportName,
+ label_x_pos: 'X Positive',
+ label_x_neg: 'X Negative',
+ label_y_pos: 'Y Positive',
+ label_y_neg: 'Y Negative',
+ label_group_0: 'Group 0',
+ label_group_1: 'Group 1'
+ });
+
+ // Should return successful response
+ expect(response.status).toBe(200);
+ expect(response.text).toBe('{}');
+
+ // Verify the update worked by fetching the report again
+ const getResponse: Response = await textAgent.get(`/api/v3/reports?conversation_id=${conversationId}`);
+ const reports: Report[] = JSON.parse(getResponse.text);
+
+ // Find our report
+ const updatedReport = reports.find((r) => r.report_id === reportId);
+ expect(updatedReport).toHaveProperty('report_name', testReportName);
+ expect(updatedReport).toHaveProperty('label_x_pos', 'X Positive');
+ expect(updatedReport).toHaveProperty('label_x_neg', 'X Negative');
+ expect(updatedReport).toHaveProperty('label_y_pos', 'Y Positive');
+ expect(updatedReport).toHaveProperty('label_y_neg', 'Y Negative');
+ expect(updatedReport).toHaveProperty('label_group_0', 'Group 0');
+ expect(updatedReport).toHaveProperty('label_group_1', 'Group 1');
+ });
+
+ test('GET /api/v3/reports - should get all reports for user', async () => {
+ const response: Response = await textAgent.get('/api/v3/reports');
+
+ // Should return successful response
+ expect(response.status).toBe(200);
+
+ // Response should contain an array of reports
+ const reports: Report[] = JSON.parse(response.text);
+ expect(Array.isArray(reports)).toBe(true);
+
+ // Our report should be included
+ const hasReport = reports.some((r) => r.report_id === reportId);
+ expect(hasReport).toBe(true);
+ });
+
+ test('GET /api/v3/reports?report_id - should get a specific report', async () => {
+ const response: Response = await textAgent.get(`/api/v3/reports?report_id=${reportId}`);
+
+ // Should return successful response
+ expect(response.status).toBe(200);
+
+ // Response should contain an array with one report
+ const reports: Report[] = JSON.parse(response.text);
+ expect(Array.isArray(reports)).toBe(true);
+ expect(reports.length).toBe(1);
+
+ // The report should have the correct ID
+ expect(reports[0]).toHaveProperty('report_id', reportId);
+ });
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/simple-supertest.test.ts b/server/__tests__/integration/simple-supertest.test.ts
new file mode 100644
index 0000000000..ddb24d49c8
--- /dev/null
+++ b/server/__tests__/integration/simple-supertest.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test, beforeAll } from '@jest/globals';
+import request from 'supertest';
+import type { Response } from 'supertest';
+import { getApp } from '../app-loader';
+import type { Express } from 'express';
+
+describe('Simple Supertest Tests', () => {
+ let app: Express;
+
+ // Initialize the app before tests run
+ beforeAll(async () => {
+ app = await getApp();
+ });
+
+ test('Health check works', async () => {
+ const response: Response = await request(app).get('/api/v3/testConnection');
+ expect(response.status).toBe(200);
+ });
+
+ test('Basic auth check works', async () => {
+ const response: Response = await request(app).post('/api/v3/auth/login').send({});
+ expect(response.status).toBe(400);
+ // Response should contain error about missing password
+ expect(response.text).toContain('polis_err_param_missing_password');
+ });
+});
diff --git a/server/__tests__/integration/tutorial.test.ts b/server/__tests__/integration/tutorial.test.ts
new file mode 100644
index 0000000000..add21da104
--- /dev/null
+++ b/server/__tests__/integration/tutorial.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test, beforeAll } from '@jest/globals';
+import {
+ generateTestUser,
+ getTestAgent,
+ getTextAgent,
+ newAgent,
+ registerAndLoginUser
+} from '../setup/api-test-helpers';
+import type { Response } from 'supertest';
+import type { TestUser } from '../../types/test-helpers';
+import { Agent } from 'supertest';
+
+describe('POST /tutorial', () => {
+ let agent: Agent;
+ let textAgent: Agent;
+
+ // Initialize agents before running tests
+ beforeAll(async () => {
+ agent = await getTestAgent();
+ textAgent = await getTextAgent();
+ });
+
+ test('should update tutorial step for authenticated user', async () => {
+ // Register and login a user
+ const testUser: TestUser = generateTestUser();
+ await registerAndLoginUser(testUser);
+
+ // Update tutorial step
+ const response: Response = await agent.post('/api/v3/tutorial').send({ step: 1 });
+
+ // Check response
+ expect(response.status).toBe(200);
+ });
+
+ test('should require authentication', async () => {
+ const testAgent = await newAgent();
+ // Try to update tutorial step without authentication
+ const response: Response = await testAgent.post('/api/v3/tutorial').send({ step: 1 });
+
+ // Expect authentication error
+ expect(response.status).toBe(500);
+ expect(response.text).toContain('polis_err_auth_token_not_supplied');
+ });
+
+ test('should require valid step parameter', async () => {
+ // Register and login a user
+ const testUser: TestUser = generateTestUser();
+ await registerAndLoginUser(testUser);
+
+ // Try to update with invalid step
+ const response: Response = await textAgent.post('/api/v3/tutorial').send({ step: 'invalid' });
+
+ // Expect validation error
+ expect(response.status).toBe(400);
+ expect(response.text).toContain('polis_err_param_parse_failed_step');
+ expect(response.text).toContain('polis_fail_parse_int invalid');
+ });
+});
diff --git a/server/__tests__/integration/users.test.ts b/server/__tests__/integration/users.test.ts
new file mode 100644
index 0000000000..137164276b
--- /dev/null
+++ b/server/__tests__/integration/users.test.ts
@@ -0,0 +1,246 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import {
+ getTestAgent,
+ getTextAgent,
+ initializeParticipantWithXid,
+ newAgent,
+ newTextAgent,
+ setupAuthAndConvo,
+ submitVote
+} from '../setup/api-test-helpers';
+import { findEmailByRecipient } from '../setup/email-helpers';
+import type { Response } from 'supertest';
+import type { TestUser } from '../../types/test-helpers';
+import { Agent } from 'supertest';
+
+interface EmailResult {
+ subject: string;
+ html?: string;
+ text?: string;
+ [key: string]: any;
+}
+
+interface UserInfo {
+ uid: number;
+ email: string;
+ hname: string;
+ hasXid?: boolean;
+ [key: string]: any;
+}
+
+describe('User Management Endpoints', () => {
+ // Declare agent variables
+ let agent: Agent;
+ let textAgent: Agent;
+
+ // Initialize agents before running tests
+ beforeAll(async () => {
+ agent = await getTestAgent();
+ textAgent = await getTextAgent();
+ });
+
+ let ownerUserId: number;
+ let testUser: TestUser;
+ let conversationId: string;
+
+ // Setup - Create a test user with admin privileges and a conversation
+ beforeAll(async () => {
+ // Setup auth and create test conversation
+ const setup = await setupAuthAndConvo({ commentCount: 3 });
+ ownerUserId = setup.userId;
+ testUser = setup.testUser;
+ conversationId = setup.conversationId;
+ });
+
+ describe('GET /users', () => {
+ test('should get the current user info when authenticated', async () => {
+ const response: Response = await agent.get('/api/v3/users');
+
+ expect(response.status).toBe(200);
+ const userInfo = response.body as UserInfo;
+ expect(userInfo).toHaveProperty('uid', ownerUserId);
+ expect(userInfo).toHaveProperty('email', testUser.email);
+ expect(userInfo).toHaveProperty('hname', testUser.hname);
+ });
+
+ test('should require authentication when errIfNoAuth is true', async () => {
+ // Create a new agent without auth
+ const unauthAgent = await newTextAgent();
+ const response: Response = await unauthAgent.get('/api/v3/users?errIfNoAuth=true');
+
+ // The server responds with 401 (authorization required)
+ expect(response.status).toBe(401);
+
+ // Check for error message in text
+ expect(response.text).toMatch(/polis_error_auth_needed/);
+ });
+
+ test('should return empty response for anonymous users when errIfNoAuth is false', async () => {
+ // Create a new agent without auth
+ const unauthAgent = await newAgent();
+ const response: Response = await unauthAgent.get('/api/v3/users?errIfNoAuth=false');
+
+ expect(response.status).toBe(200);
+
+ // Legacy API returns an empty object for anonymous users
+ expect(response.body).toEqual({});
+ });
+
+ test('should handle user lookup by XID', async () => {
+ // Create a random XID for testing
+ const testXid = `test-xid-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
+
+ // Initialize an XID-based participant in the conversation
+ const { agent: xidAgent, body, status } = await initializeParticipantWithXid(conversationId, testXid);
+
+ expect(status).toBe(200);
+ expect(body).toHaveProperty('nextComment');
+ const nextComment = body.nextComment;
+ expect(nextComment).toBeDefined();
+ expect(nextComment.tid).toBeDefined();
+
+ // Vote to establish the xid user in the conversation
+ await submitVote(xidAgent, {
+ conversation_id: conversationId,
+ vote: -1, // upvote
+ tid: nextComment.tid,
+ xid: testXid
+ });
+
+ const lookupResponse: Response = await agent.get(`/api/v3/users?owner_uid=${ownerUserId}&xid=${testXid}`);
+
+ expect(lookupResponse.status).toBe(200);
+
+ // Returns the caller's user info, not the xid user info
+ // This is a legacy behavior, and is not what we want.
+ const userInfo = lookupResponse.body as UserInfo;
+ expect(userInfo).toHaveProperty('email', testUser.email);
+ expect(userInfo).toHaveProperty('hasXid', false);
+ expect(userInfo).toHaveProperty('hname', testUser.hname);
+ expect(userInfo).toHaveProperty('uid', ownerUserId);
+ });
+ });
+
+ describe('PUT /users', () => {
+ test('should update user information', async () => {
+ const newName = `Updated Test User ${Date.now()}`;
+
+ const response: Response = await agent.put('/api/v3/users').send({
+ hname: newName
+ });
+
+ expect(response.status).toBe(200);
+
+ // Verify the update by getting user info
+ const userInfo: Response = await agent.get('/api/v3/users');
+ expect(userInfo.status).toBe(200);
+ expect(userInfo.body).toHaveProperty('hname', newName);
+ });
+
+ test('should require authentication', async () => {
+ // Use an unauthenticated agent
+ const unauthAgent = await newAgent();
+ const response: Response = await unauthAgent.put('/api/v3/users').send({
+ hname: 'Unauthenticated Update'
+ });
+
+ expect(response.status).toBe(500);
+ expect(response.text).toMatch(/polis_err_auth_token_not_supplied/);
+ });
+
+ test('should validate email format', async () => {
+ const response: Response = await textAgent.put('/api/v3/users').send({
+ email: 'invalid-email'
+ });
+
+ // The server should reject invalid email formats
+ expect(response.status).toBe(400);
+ expect(response.text).toMatch(/polis_err_param_parse_failed_email/);
+ expect(response.text).toMatch(/polis_fail_parse_email/);
+ });
+ });
+
+ describe('POST /users/invite', () => {
+ test('should send invites to a conversation', async () => {
+ const timestamp = Date.now();
+ // NOTE: The DB restricts emails to 32 characters!
+ const testEmails = [`invite.${timestamp}.1@test.com`, `invite.${timestamp}.2@test.com`];
+
+ const response: Response = await agent.post('/api/v3/users/invite').send({
+ conversation_id: conversationId,
+ emails: testEmails.join(',')
+ });
+
+ expect(response.status).toBe(200);
+ // The legacy server returns a 200 with a status property of ':-)'. Yep.
+ expect(response.body).toHaveProperty('status', ':-)');
+
+ // Find the emails in MailDev
+ const email1 = await findEmailByRecipient(testEmails[0]) as EmailResult | null;
+ const email2 = await findEmailByRecipient(testEmails[1]) as EmailResult | null;
+
+ // Test should fail if we don't find both emails
+ if (!email1) {
+ throw new Error(
+ `Email verification failed: No email found for recipient ${testEmails[0]}. Is MailDev running?`
+ );
+ }
+ if (!email2) {
+ throw new Error(
+ `Email verification failed: No email found for recipient ${testEmails[1]}. Is MailDev running?`
+ );
+ }
+
+ // Verify email content
+ expect(email1.subject).toMatch(/Join the pol.is conversation!/i);
+ expect(email1.html || email1.text).toContain(conversationId);
+
+ expect(email2.subject).toMatch(/Join the pol.is conversation!/i);
+ expect(email2.html || email2.text).toContain(conversationId);
+ });
+
+ test('should require authentication', async () => {
+ // Use an unauthenticated agent
+ const unauthAgent = await newAgent();
+ const response: Response = await unauthAgent.post('/api/v3/users/invite').send({
+ conversation_id: conversationId,
+ emails: `unauthenticated.${Date.now()}@example.com`
+ });
+
+ expect(response.status).toBe(500);
+ expect(response.text).toMatch(/polis_err_auth_token_not_supplied/);
+ });
+
+ test('should require valid conversation ID', async () => {
+ const response: Response = await textAgent.post('/api/v3/users/invite').send({
+ conversation_id: 'invalid-conversation-id',
+ emails: `invalid-convo.${Date.now()}@example.com`
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.text).toMatch(/polis_err_param_parse_failed_conversation_id/);
+ expect(response.text).toMatch(/polis_err_fetching_zid_for_conversation_id/);
+ });
+
+ test('should require email addresses', async () => {
+ const response: Response = await textAgent.post('/api/v3/users/invite').send({
+ conversation_id: conversationId
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.text).toMatch(/polis_err_param_missing_emails/);
+ });
+
+ test('should validate email format', async () => {
+ const response: Response = await agent.post('/api/v3/users/invite').send({
+ conversation_id: conversationId,
+ emails: 'invalid-email'
+ });
+
+ // The server should reject invalid email formats
+ // However, the legacy server just returns a 200
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveProperty('status', ':-)');
+ });
+ });
+});
diff --git a/server/__tests__/integration/vote.test.ts b/server/__tests__/integration/vote.test.ts
new file mode 100644
index 0000000000..4beccd2f35
--- /dev/null
+++ b/server/__tests__/integration/vote.test.ts
@@ -0,0 +1,183 @@
+import { beforeEach, describe, expect, test } from '@jest/globals';
+import type { Response } from 'supertest';
+import {
+ getMyVotes,
+ getVotes,
+ initializeParticipant,
+ setupAuthAndConvo,
+ submitVote,
+ VoteResponse
+} from '../setup/api-test-helpers';
+
+describe('Vote API', () => {
+ let conversationId: string;
+ let commentId: number;
+
+ beforeEach(async () => {
+ // Setup auth, conversation, and comments
+ const setup = await setupAuthAndConvo({ commentCount: 1 });
+ conversationId = setup.conversationId;
+ commentId = setup.commentIds[0];
+ });
+
+ describe('POST /votes', () => {
+ test('should create a vote for a comment', async () => {
+ // Initialize a participant
+ const { agent: participantAgent } = await initializeParticipant(conversationId);
+
+ // Submit a vote (-1 = AGREE)
+ const voteResponse = await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: -1 // -1 = AGREE in this system
+ });
+
+ expect(voteResponse.status).toBe(200);
+ expect(voteResponse.body).toHaveProperty('currentPid');
+ });
+
+ test('should require a valid conversation_id', async () => {
+ const { agent: participantAgent } = await initializeParticipant(conversationId);
+
+ const response = await submitVote(participantAgent, {
+ conversation_id: 'invalid_conversation_id',
+ tid: commentId,
+ vote: 0
+ });
+
+ // The API returns 400 for missing required parameters
+ expect(response.status).toBe(400);
+
+ expect(response.text).toMatch(/polis_err_param_parse_failed_conversation_id/);
+ expect(response.text).toMatch(/polis_err_fetching_zid_for_conversation_id/);
+ });
+
+ test('should require a valid tid', async () => {
+ const { agent: participantAgent } = await initializeParticipant(conversationId);
+
+ // Using non-null assertion since we know this won't be null in our test
+ const response = await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: 'invalid_tid' as unknown as number,
+ vote: 0
+ });
+
+ // The API returns 400 for missing required parameters
+ expect(response.status).toBe(400);
+ expect(response.text).toMatch(/polis_err_param_parse_failed_tid/);
+ expect(response.text).toMatch(/polis_fail_parse_int/);
+ });
+
+ test('should accept votes of -1, 0, or 1', async () => {
+ const { agent: participantAgent } = await initializeParticipant(conversationId);
+
+ // Vote 1 (DISAGREE)
+ const disagreeResponse = await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: 1 // 1 = DISAGREE in this system
+ });
+ expect(disagreeResponse.status).toBe(200);
+
+ // Vote 0 (PASS)
+ const passResponse = await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: 0 // 0 = PASS
+ });
+ expect(passResponse.status).toBe(200);
+
+ // Vote -1 (AGREE)
+ const agreeResponse = await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: -1 // -1 = AGREE in this system
+ });
+ expect(agreeResponse.status).toBe(200);
+ });
+
+ test('should allow vote modification', async () => {
+ // Initialize a participant
+ const { agent: participantAgent } = await initializeParticipant(conversationId);
+
+ // Submit initial vote (AGREE)
+ const initialVoteResponse = await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: -1 // -1 = AGREE in this system
+ });
+
+ expect(initialVoteResponse.status).toBe(200);
+ expect(initialVoteResponse.body).toHaveProperty('currentPid');
+ const { currentPid } = initialVoteResponse.body;
+ expect(currentPid).toBeDefined();
+ expect(typeof currentPid).toBe('number');
+
+ // Change vote to DISAGREE
+ const changedVoteResponse = await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: 1, // 1 = DISAGREE in this system
+ pid: currentPid as string
+ });
+
+ expect(changedVoteResponse.status).toBe(200);
+ expect(changedVoteResponse.body).toBeDefined();
+
+ const votes = await getVotes(participantAgent, conversationId, currentPid as string);
+ expect(votes.length).toBe(1);
+ expect(votes[0].vote).toBe(1);
+ });
+ });
+
+ describe('GET /votes', () => {
+ test('should retrieve votes for a conversation', async () => {
+ // Create a participant and submit a vote
+ const { agent: participantAgent } = await initializeParticipant(conversationId);
+
+ const voteResponse = await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: -1 // -1 = AGREE in this system
+ });
+
+ expect(voteResponse.status).toBe(200);
+ expect(voteResponse.body).toHaveProperty('currentPid');
+ const { currentPid } = voteResponse.body;
+ expect(currentPid).toBeDefined();
+ expect(typeof currentPid).toBe('number');
+
+ // Retrieve votes
+ const votes = await getVotes(participantAgent, conversationId, currentPid as string);
+
+ expect(votes.length).toBe(1);
+ expect(votes[0].vote).toBe(-1);
+ });
+ });
+
+ describe('GET /votes/me', () => {
+ test('should retrieve votes for the current participant', async () => {
+ // Create a participant and submit a vote
+ const { agent: participantAgent } = await initializeParticipant(conversationId);
+
+ const voteResponse = await submitVote(participantAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: -1 // -1 = AGREE in this system
+ });
+
+ expect(voteResponse.status).toBe(200);
+ expect(voteResponse.body).toHaveProperty('currentPid');
+ const { currentPid } = voteResponse.body;
+ expect(currentPid).toBeDefined();
+ expect(typeof currentPid).toBe('number');
+
+ // Retrieve personal votes
+ const myVotes = await getMyVotes(participantAgent, conversationId, currentPid as string);
+
+ // NOTE: The legacy endpoint returns an empty array.
+ expect(Array.isArray(myVotes)).toBe(true);
+ expect(myVotes.length).toBe(0);
+ });
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/integration/xid-auth.test.ts b/server/__tests__/integration/xid-auth.test.ts
new file mode 100644
index 0000000000..4f65c3df00
--- /dev/null
+++ b/server/__tests__/integration/xid-auth.test.ts
@@ -0,0 +1,132 @@
+import { beforeAll, describe, expect, test } from '@jest/globals';
+import type { Response } from 'supertest';
+import {
+ createConversation,
+ generateRandomXid,
+ initializeParticipantWithXid,
+ registerAndLoginUser,
+ submitVote
+} from '../setup/api-test-helpers';
+
+interface UserInfo {
+ uid: number;
+ hasXid: boolean;
+ xInfo: {
+ xid: string;
+ [key: string]: any;
+ };
+ [key: string]: any;
+}
+
+interface ParticipationResponse {
+ user: UserInfo;
+ conversation: {
+ conversation_id: string;
+ [key: string]: any;
+ };
+ nextComment: {
+ tid?: number;
+ currentPid?: string;
+ [key: string]: any;
+ };
+ votes: Array<{
+ tid: number;
+ vote: number;
+ [key: string]: any;
+ }>;
+ [key: string]: any;
+}
+
+describe('XID-based Authentication', () => {
+ let agent: ReturnType['agent'];
+ let conversationId: string;
+ let commentId: number;
+
+ beforeAll(async () => {
+ // Create an authenticated user
+ const auth = await registerAndLoginUser();
+ agent = auth.agent;
+
+ // Create a conversation
+ conversationId = await createConversation(agent);
+
+ // Create a comment in the conversation
+ const response: Response = await agent.post('/api/v3/comments').send({
+ conversation_id: conversationId,
+ txt: 'Test comment for XID authentication testing'
+ });
+
+ expect(response.status).toBe(200);
+ commentId = response.body.tid;
+ });
+
+ test('should initialize participation with XID', async () => {
+ const xid = generateRandomXid();
+
+ const { status, body } = await initializeParticipantWithXid(conversationId, xid);
+
+ expect(status).toBe(200);
+ expect(body).toHaveProperty('conversation');
+ expect(body).toHaveProperty('nextComment');
+ expect(body.conversation.conversation_id).toBe(conversationId);
+
+ // Should have the comment we created
+ expect(body.nextComment.tid).toBe(commentId);
+
+ // The participant should be associated with the XID
+ // but we can't easily verify that directly from the response
+ });
+
+ test('should maintain XID association across multiple sessions', async () => {
+ const xid = generateRandomXid();
+
+ // First session
+ const { agent: firstSessionAgent } = await initializeParticipantWithXid(conversationId, xid);
+
+ // Vote on a comment
+ const firstVoteResponse = await submitVote(firstSessionAgent, {
+ conversation_id: conversationId,
+ tid: commentId,
+ vote: -1, // Agree
+ xid: xid
+ });
+
+ expect(firstVoteResponse.status).toBe(200);
+
+ // Second session with same XID
+ const { body: secondSessionBody } = await initializeParticipantWithXid(conversationId, xid);
+ const responseBody = secondSessionBody as ParticipationResponse;
+
+ const { user, nextComment, votes } = responseBody;
+
+ // user should be defined and have the xid info
+ expect(user.uid).toBeDefined();
+ expect(user.hasXid).toBe(true);
+ expect(user.xInfo.xid).toBe(xid);
+
+ // nextComment should not comtain a comment
+ expect(nextComment.tid).toBeUndefined();
+ expect(nextComment.currentPid).toBeDefined();
+
+ // the vote should be the same as the one we made in the first session
+ expect(votes).toBeInstanceOf(Array);
+ expect(votes.length).toBe(1);
+ expect(votes[0].vote).toBe(-1);
+ expect(votes[0].tid).toBe(commentId);
+ });
+
+ test('should format XID whitelist properly', async () => {
+ // Create XIDs to whitelist
+ const xids = [generateRandomXid(), generateRandomXid(), generateRandomXid()];
+
+ // Attempt to whitelist string XIDs (expect error)
+ const whitelistResponse: Response = await agent.post('/api/v3/xidWhitelist').send({
+ xid_whitelist: xids.join(',')
+ });
+
+ // Returns 200 with empty body
+ // There is no endpoint to get the whitelist
+ expect(whitelistResponse.status).toBe(200);
+ expect(whitelistResponse.body).toEqual({});
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/setup/api-test-helpers.ts b/server/__tests__/setup/api-test-helpers.ts
new file mode 100644
index 0000000000..fa7e4850eb
--- /dev/null
+++ b/server/__tests__/setup/api-test-helpers.ts
@@ -0,0 +1,849 @@
+import crypto from 'crypto';
+import dotenv from 'dotenv';
+import request from 'supertest';
+import type { Response } from 'supertest';
+import type { Express } from 'express';
+import type {
+ TestUser,
+ AuthData,
+ ConvoData,
+ ParticipantData,
+ VoteData,
+ VoteResponse,
+ ConversationOptions,
+ CommentOptions,
+ ValidationOptions
+} from '../../types/test-helpers';
+
+// Import the Express app via our controlled loader
+import { getApp } from '../app-loader';
+
+// Async version for more reliable initialization
+async function getAppInstance(): Promise {
+ return await getApp();
+}
+
+// Use { override: false } to prevent dotenv from overriding command-line env vars
+dotenv.config({ override: false });
+
+// Set environment variables for testing
+process.env.NODE_ENV = 'test';
+process.env.TESTING = 'true';
+
+// ASYNC getter functions
+async function getTestAgent(): Promise> {
+ // Use type assertion for global access
+ if (!(globalThis as any).__TEST_AGENT__) {
+ const app = await getAppInstance();
+ (globalThis as any).__TEST_AGENT__ = request.agent(app);
+ }
+ // Ensure it's not null before returning
+ if (!(globalThis as any).__TEST_AGENT__) {
+ throw new Error('Failed to initialize __TEST_AGENT__');
+ }
+ return (globalThis as any).__TEST_AGENT__;
+}
+
+// ASYNC getter functions
+async function getTextAgent(): Promise> {
+ // Use type assertion for global access
+ if (!(globalThis as any).__TEXT_AGENT__) {
+ const app = await getAppInstance();
+ (globalThis as any).__TEXT_AGENT__ = createTextAgent(app);
+ }
+ // Ensure it's not null before returning
+ if (!(globalThis as any).__TEXT_AGENT__) {
+ throw new Error('Failed to initialize __TEXT_AGENT__');
+ }
+ return (globalThis as any).__TEXT_AGENT__;
+}
+
+// ASYNC newAgent function
+async function newAgent(): Promise> {
+ const app = await getAppInstance();
+ return request.agent(app);
+}
+
+// ASYNC newTextAgent function
+async function newTextAgent(): Promise> {
+ const app = await getAppInstance();
+ return createTextAgent(app);
+}
+
+/**
+ * Create an agent that handles text responses properly
+ * Use this when you need to maintain cookies across requests but still handle text responses
+ *
+ * @param app - Express app instance
+ * @returns Supertest agent with custom parser
+ */
+function createTextAgent(app: Express): ReturnType {
+ const agent = request.agent(app);
+ agent.parse((res, fn) => {
+ res.setEncoding('utf8');
+ res.text = '';
+ res.on('data', (chunk) => {
+ res.text += chunk;
+ });
+ res.on('end', () => {
+ fn(null, res.text);
+ });
+ });
+ return agent;
+}
+
+/**
+ * Helper to generate random test user data
+ * @returns Random user data for registration
+ */
+function generateTestUser(): TestUser {
+ const timestamp = Date.now();
+ const randomSuffix = Math.floor(Math.random() * 10000);
+
+ return {
+ email: `test.user.${timestamp}.${randomSuffix}@example.com`,
+ password: `TestPassword${randomSuffix}!`,
+ hname: `Test User ${timestamp}`
+ };
+}
+
+/**
+ * Helper to generate a random external ID
+ * @returns Random XID
+ */
+function generateRandomXid(): string {
+ const timestamp = Date.now();
+ const randomSuffix = Math.floor(Math.random() * 10000);
+ return `test-xid-${timestamp}-${randomSuffix}`;
+}
+
+/**
+ * Helper function to wait/pause execution
+ * @param ms - Milliseconds to wait
+ * @returns Promise that resolves after the specified time
+ */
+const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));
+
+/**
+ * Helper to create a test conversation using a supertest agent
+ * @param agent - Supertest agent to use for the request
+ * @param options - Conversation options
+ * @returns Created conversation ID (zinvite)
+ */
+async function createConversation(
+ agent: ReturnType,
+ options: ConversationOptions = {}
+): Promise {
+ const timestamp = Date.now();
+ const defaultOptions = {
+ topic: `Test Conversation ${timestamp}`,
+ description: `This is a test conversation created at ${timestamp}`,
+ is_active: true,
+ is_anon: true,
+ is_draft: false,
+ strict_moderation: false,
+ profanity_filter: false, // Disable profanity filter for testing
+ ...options
+ };
+
+ const response = await agent.post('/api/v3/conversations').send(defaultOptions);
+
+ // Validate response
+ if (response.status !== 200) {
+ throw new Error(`Failed to create conversation: ${response.status} ${response.text}`);
+ }
+
+ try {
+ // Try to parse the response text as JSON
+ const jsonData = JSON.parse(response.text);
+ return jsonData.conversation_id;
+ } catch (error) {
+ if (error instanceof Error) {
+ throw new Error(`Failed to parse conversation response: ${error.message}, Response: ${response.text}`);
+ }
+ throw error;
+ }
+}
+
+/**
+ * Helper to create a test comment using a supertest agent
+ * @param agent - Supertest agent to use for the request
+ * @param conversationId - Conversation ID (zinvite)
+ * @param options - Comment options
+ * @returns Created comment ID
+ */
+async function createComment(
+ agent: ReturnType,
+ conversationId: string,
+ options: CommentOptions = {} as CommentOptions
+): Promise {
+ if (!conversationId) {
+ throw new Error('Conversation ID is required to create a comment');
+ }
+
+ const defaultOptions = {
+ agid: 1,
+ is_active: true,
+ pid: 'mypid',
+ ...options,
+ conversation_id: options.conversation_id || conversationId,
+ txt: options.txt || `This is a test comment created at ${Date.now()}`
+ };
+
+ const response = await agent.post('/api/v3/comments').send(defaultOptions);
+
+ // Validate response
+ if (response.status !== 200) {
+ throw new Error(`Failed to create comment: ${response.status} ${response.text}`);
+ }
+
+ const responseBody = parseResponseJSON(response);
+ const commentId = responseBody.tid;
+ const cookies = response.headers['set-cookie'] || [];
+ authenticateAgent(agent, cookies);
+
+ await wait(500); // Wait for comment to be created
+
+ return commentId;
+}
+
+/**
+ * Helper function to extract a specific cookie value from a cookie array
+ * @param cookies - Array of cookies from response
+ * @param cookieName - Name of the cookie to extract
+ * @returns Cookie value or null if not found
+ */
+function extractCookieValue(cookies: string[] | string | undefined, cookieName: string): string | null {
+ if (!cookies) {
+ return null;
+ }
+
+ // Handle string array
+ if (Array.isArray(cookies)) {
+ if (cookies.length === 0) {
+ return null;
+ }
+
+ for (const cookie of cookies) {
+ if (cookie.startsWith(`${cookieName}=`)) {
+ return cookie.split(`${cookieName}=`)[1].split(';')[0];
+ }
+ }
+ }
+ // Handle single cookie string
+ else if (typeof cookies === 'string') {
+ const cookieParts = cookies.split(';');
+ for (const part of cookieParts) {
+ const trimmed = part.trim();
+ if (trimmed.startsWith(`${cookieName}=`)) {
+ return trimmed.split(`${cookieName}=`)[1];
+ }
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Enhanced registerAndLoginUser that works with supertest agents
+ * Maintains the same API as the original function but uses agents internally
+ *
+ * @param userData - User data for registration
+ * @returns Object containing authToken and userId
+ */
+async function registerAndLoginUser(userData: TestUser | null = null): Promise {
+ // Use async agent getting to ensure app is initialized
+ const agent = await getTestAgent();
+ const textAgent = await getTextAgent();
+
+ // Generate user data if not provided
+ const testUser = userData || generateTestUser();
+
+ // Register the user
+ const registerResponse = await textAgent.post('/api/v3/auth/new').send({
+ ...testUser,
+ password2: testUser.password,
+ gatekeeperTosPrivacy: true
+ });
+
+ // Validate registration response
+ if (registerResponse.status !== 200) {
+ throw new Error(`Failed to register user: ${registerResponse.status} ${registerResponse.text}`);
+ }
+
+ // Login with the user
+ const loginResponse = await agent.post('/api/v3/auth/login').send({
+ email: testUser.email,
+ password: testUser.password
+ });
+
+ // Validate login response
+ if (loginResponse.status !== 200) {
+ throw new Error(`Failed to login user: ${loginResponse.status} ${loginResponse.text}`);
+ }
+
+ const loginBody = parseResponseJSON(loginResponse);
+
+ // Get cookies for API compatibility
+ const loginCookies = loginResponse.headers['set-cookie'] || [];
+ authenticateGlobalAgents(loginCookies);
+
+ // For compatibility with existing tests
+ return {
+ cookies: loginCookies,
+ userId: loginBody.uid,
+ agent, // Return the authenticated agent
+ textAgent, // Return the text agent for error cases
+ testUser
+ };
+}
+
+/**
+ * Enhanced setupAuthAndConvo that works with supertest agents
+ * Maintains the same API as the original function but uses agents internally
+ *
+ * @param options - Options for setup
+ * @returns Object containing auth token, userId, and conversation info
+ */
+async function setupAuthAndConvo(options: {
+ createConvo?: boolean;
+ commentCount?: number;
+ conversationOptions?: ConversationOptions;
+ commentOptions?: CommentOptions;
+ userData?: TestUser;
+} = {}): Promise {
+ const { createConvo = true, commentCount = 1, conversationOptions = {}, commentOptions = {} } = options;
+
+ // Use async agent getting to ensure app is initialized
+ const agent = await getTestAgent();
+
+ // Register and login
+ const testUser = options.userData || generateTestUser();
+ const { userId } = await registerAndLoginUser(testUser);
+
+ const commentIds: number[] = [];
+ let conversationId = '';
+
+ // Create test conversation if requested
+ if (createConvo) {
+ const timestamp = Date.now();
+ const convoOptions = {
+ topic: `Test Conversation ${timestamp}`,
+ description: `This is a test conversation created at ${timestamp}`,
+ is_active: true,
+ is_anon: true,
+ is_draft: false,
+ strict_moderation: false,
+ profanity_filter: false,
+ ...conversationOptions
+ };
+
+ conversationId = await createConversation(agent, convoOptions);
+
+ if (conversationId === null || conversationId === undefined) {
+ throw new Error('Failed to create conversation');
+ }
+
+ // Create test comments if commentCount is specified
+ if (commentCount > 0) {
+ for (let i = 0; i < commentCount; i++) {
+ const commentData = {
+ conversation_id: conversationId,
+ txt: `Test comment ${i + 1}`,
+ ...commentOptions
+ };
+
+ const commentId = await createComment(agent, conversationId, commentData);
+
+ if (commentId == null || commentId === undefined) {
+ throw new Error('Failed to create comment');
+ }
+
+ commentIds.push(commentId);
+ }
+ }
+ }
+
+ return {
+ userId,
+ testUser,
+ conversationId,
+ commentIds
+ };
+}
+
+/**
+ * Enhanced helper to initialize a participant with better cookie handling using supertest agents
+ *
+ * @param conversationId - Conversation zinvite
+ * @returns Participant data with cookies, body, status and agent
+ */
+async function initializeParticipant(conversationId: string): Promise {
+ // Use async agent creation to ensure app is initialized
+ const participantAgent = await newAgent();
+
+ const response = await participantAgent.get(
+ `/api/v3/participationInit?conversation_id=${conversationId}&pid=mypid&lang=en`
+ );
+
+ if (response.status !== 200) {
+ throw new Error(`Failed to initialize anonymous participant. Status: ${response.status}`);
+ }
+
+ // Extract cookies
+ const cookies = response.headers['set-cookie'] || [];
+ authenticateAgent(participantAgent, cookies);
+
+ return {
+ cookies,
+ body: parseResponseJSON(response),
+ status: response.status,
+ agent: participantAgent // Return an authenticated agent for the participant
+ };
+}
+
+/**
+ * Enhanced initializeParticipantWithXid using supertest agents
+ *
+ * @param conversationId - Conversation zinvite
+ * @param xid - External ID (generated or provided)
+ * @returns Participant data including cookies, body, status and agent
+ */
+async function initializeParticipantWithXid(conversationId: string, xid: string | null = null): Promise {
+ // Use async agent creation to ensure app is initialized
+ const participantAgent = await newAgent();
+
+ // Generate XID if not provided
+ const participantXid = xid || generateRandomXid();
+
+ const response = await participantAgent.get(
+ `/api/v3/participationInit?conversation_id=${conversationId}&xid=${participantXid}&pid=mypid&lang=en`
+ );
+
+ if (response.status !== 200) {
+ throw new Error(`Failed to initialize participant with XID. Status: ${response.status}`);
+ }
+
+ // Extract cookies
+ const cookies = response.headers['set-cookie'] || [];
+ authenticateAgent(participantAgent, cookies);
+
+ return {
+ cookies,
+ body: parseResponseJSON(response),
+ status: response.status,
+ agent: participantAgent, // Return an authenticated agent for the participant
+ xid: participantXid // Return the XID that was used
+ };
+}
+
+/**
+ * Enhanced submitVote using supertest agents
+ *
+ * @param agent - Supertest agent
+ * @param options - Vote options
+ * @returns Vote response
+ */
+async function submitVote(
+ agent: ReturnType | null,
+ options: VoteData = {} as VoteData
+): Promise {
+ // Error if options does not have tid or conversation_id
+ // NOTE: 0 is a valid value for tid or conversation_id
+ if (options.tid === undefined || options.conversation_id === undefined) {
+ throw new Error('Options must have tid or conversation_id to vote');
+ }
+ // Ensure agent is initialized if not provided
+ const voterAgent = agent || await getTestAgent();
+
+ // Create vote payload
+ const voteData = {
+ agid: 1,
+ high_priority: false,
+ lang: 'en',
+ pid: 'mypid',
+ ...options,
+ vote: options.vote !== undefined ? options.vote : 0
+ };
+
+ const response = await voterAgent.post('/api/v3/votes').send(voteData);
+
+ await wait(500); // Wait for vote to be processed
+
+ const cookies = response.headers['set-cookie'] || [];
+ authenticateAgent(voterAgent, cookies);
+
+ return {
+ cookies,
+ body: parseResponseJSON(response),
+ text: response.text,
+ status: response.status,
+ agent: voterAgent // Return the agent for chaining
+ };
+}
+
+/**
+ * Retrieves votes for a conversation
+ * @param agent - Supertest agent
+ * @param conversationId - Conversation ID
+ * @param pid - Participant ID
+ * @returns - Array of votes
+ */
+async function getVotes(
+ agent: ReturnType,
+ conversationId: string,
+ pid: string
+): Promise {
+ // Get votes for the conversation
+ const response = await agent.get(`/api/v3/votes?conversation_id=${conversationId}&pid=${pid}`);
+
+ // Validate response
+ validateResponse(response, {
+ expectedStatus: 200,
+ errorPrefix: 'Failed to get votes'
+ });
+
+ return response.body;
+}
+
+/**
+ * Retrieves votes for the current participant in a conversation
+ * @param agent - Supertest agent
+ * @param conversationId - Conversation ID
+ * @param pid - Participant ID
+ * @returns - Array of votes
+ */
+async function getMyVotes(
+ agent: ReturnType,
+ conversationId: string,
+ pid: string
+): Promise {
+ // Get votes for the participant
+ const response = await agent.get(`/api/v3/votes/me?conversation_id=${conversationId}&pid=${pid}`);
+
+ // Validate response
+ validateResponse(response, {
+ expectedStatus: 200,
+ errorPrefix: 'Failed to get my votes'
+ });
+
+ // NOTE: This endpoint seems to return a 200 status with an empty array.
+ return response.body;
+}
+
+/**
+ * Updates a conversation using query params
+ * @param agent - Supertest agent
+ * @param params - Update parameters
+ * @returns - API response
+ */
+async function updateConversation(
+ agent: ReturnType,
+ params: { conversation_id: string; [key: string]: any } = { conversation_id: '' }
+): Promise {
+ if (params.conversation_id === undefined) {
+ throw new Error('conversation_id is required to update a conversation');
+ }
+
+ return agent.put('/api/v3/conversations').send(params);
+}
+
+/**
+ * Helper function to safely check for response properties, handling falsy values correctly
+ * @param response - API response object
+ * @param propertyPath - Dot-notation path to property (e.g., 'body.tid')
+ * @returns - True if property exists and is not undefined/null
+ */
+function hasResponseProperty(response: any, propertyPath: string): boolean {
+ if (!response) return false;
+
+ const parts = propertyPath.split('.');
+ let current = response;
+
+ for (const part of parts) {
+ // 0, false, and empty string are valid values
+ if (current[part] === undefined || current[part] === null) {
+ return false;
+ }
+ current = current[part];
+ }
+
+ return true;
+}
+
+/**
+ * Formats an error message from a response
+ * @param response - The API response
+ * @param prefix - Error message prefix
+ * @returns - Formatted error message
+ */
+function formatErrorMessage(response: Response, prefix = 'API error'): string {
+ const errorMessage =
+ typeof response.body === 'string' ? response.body : response.text || JSON.stringify(response.body);
+ return `${prefix}: ${response.status} ${errorMessage}`;
+}
+
+/**
+ * Validates a response and throws an error if invalid
+ * @param response - The API response
+ * @param options - Validation options
+ * @returns - The response if valid
+ * @throws - If response is invalid
+ */
+function validateResponse(response: Response, options: ValidationOptions = {}): Response {
+ const { expectedStatus = 200, errorPrefix = 'API error', requiredProperties = [] } = options;
+
+ // Check status
+ if (response.status !== expectedStatus) {
+ throw new Error(formatErrorMessage(response, errorPrefix));
+ }
+
+ // Check required properties
+ for (const prop of requiredProperties) {
+ if (!hasResponseProperty(response, prop)) {
+ throw new Error(`${errorPrefix}: Missing required property '${prop}'`);
+ }
+ }
+
+ return response;
+}
+
+/**
+ * Helper function to authenticate a supertest agent with a token
+ * @param agent - The supertest agent to authenticate
+ * @param token - Auth token or cookie array
+ * @returns - The authenticated agent (for chaining)
+ */
+function authenticateAgent(
+ agent: ReturnType,
+ token: string[] | string | undefined
+): ReturnType {
+ if (!token || (Array.isArray(token) && token.length === 0)) {
+ return agent;
+ }
+
+ if (Array.isArray(token)) {
+ // Handle cookie array
+ const cookieString = token.map((c) => c.split(';')[0]).join('; ');
+ agent.set('Cookie', cookieString);
+ } else if (typeof token === 'string' && (token.includes(';') || token.startsWith('token2='))) {
+ // Handle cookie string
+ agent.set('Cookie', token);
+ } else if (typeof token === 'string') {
+ // Handle x-polis token
+ agent.set('x-polis', token);
+ }
+
+ return agent;
+}
+
+/**
+ * Helper function to authenticate both global agents with the same token
+ * Use this when you need to maintain the same auth state across both agents
+ *
+ * @param token - Auth token or cookie array
+ * @returns - Object containing both authenticated agents
+ */
+function authenticateGlobalAgents(token: string[] | string | undefined): {
+ agent: ReturnType;
+ textAgent: ReturnType;
+} {
+ // Use type assertion for global access
+ if (!(globalThis as any).__TEST_AGENT__ || !(globalThis as any).__TEXT_AGENT__) {
+ // This might happen if called very early, before globalSetup or async getters run.
+ // Depending on usage, might need to make this function async and await getTestAgent()/getTextAgent().
+ // For now, throw error to highlight the potential issue.
+ throw new Error('Global agents not initialized. Cannot authenticate synchronously.');
+ }
+ const agent = (globalThis as any).__TEST_AGENT__; // Access directly AFTER ensuring they exist
+ const textAgent = (globalThis as any).__TEXT_AGENT__; // Access directly AFTER ensuring they exist
+
+ if (!token || (Array.isArray(token) && token.length === 0)) {
+ return { agent, textAgent };
+ }
+
+ if (Array.isArray(token)) {
+ // Handle cookie array
+ const cookieString = token.map((c) => c.split(';')[0]).join('; ');
+ agent.set('Cookie', cookieString);
+ textAgent.set('Cookie', cookieString);
+ } else if (typeof token === 'string' && (token.includes(';') || token.startsWith('token2='))) {
+ // Handle cookie string
+ agent.set('Cookie', token);
+ textAgent.set('Cookie', token);
+ } else if (typeof token === 'string') {
+ // Handle x-polis token
+ agent.set('x-polis', token);
+ textAgent.set('x-polis', token);
+ }
+
+ return { agent, textAgent };
+}
+
+/**
+ * Helper to parse response text safely
+ *
+ * @param response - Response object
+ * @returns Parsed JSON or empty object
+ */
+function parseResponseJSON(response: Response): any {
+ try {
+ if (response?.text) {
+ return JSON.parse(response.text);
+ }
+ return {};
+ } catch (e) {
+ console.error('Error parsing JSON response:', e);
+ return {};
+ }
+}
+
+// Utility function to create HMAC signature for email verification
+function createHmacSignature(email: string, conversationId: string, path = 'api/v3/notifications/subscribe'): string {
+ // This should match the server's HMAC generation logic
+ const serverKey = 'G7f387ylIll8yuskuf2373rNBmcxqWYFfHhdsd78f3uekfs77EOLR8wofw';
+ const hmac = crypto.createHmac('sha1', serverKey);
+ hmac.setEncoding('hex');
+
+ // Create params object
+ const params = {
+ conversation_id: conversationId,
+ email: email
+ };
+
+ // Create the full string exactly as the server does
+ path = path.replace(/\/$/, ''); // Remove trailing slash if present
+ const paramString = Object.entries(params)
+ .sort(([a], [b]) => a > b ? 1 : -1)
+ .map(([key, value]) => `${key}=${value}`)
+ .join('&');
+
+ const fullString = `${path}?${paramString}`;
+
+ // Write the full string and get the hash exactly as the server does
+ hmac.write(fullString);
+ hmac.end();
+ const hash = hmac.read();
+
+ return hash.toString();
+}
+
+/**
+ * Populates a conversation with participants, comments, and votes
+ * Creates a rich dataset suitable for testing math/analysis features
+ *
+ * @param options - Configuration options
+ * @returns Object containing arrays of created participants, comments, and votes
+ */
+async function populateConversationWithVotes(options: {
+ conversationId: string;
+ numParticipants?: number;
+ numComments?: number;
+} = { conversationId: '' }): Promise<{
+ participants: ReturnType[];
+ comments: number[];
+ votes: { participantIndex: number; commentId: number; vote: number; pid: string }[];
+ stats: { numParticipants: number; numComments: number; totalVotes: number };
+}> {
+ const { conversationId, numParticipants = 3, numComments = 3 } = options;
+
+ if (!conversationId) {
+ throw new Error('conversationId is required');
+ }
+
+ const participants: ReturnType[] = [];
+ const comments: number[] = [];
+ const votes: { participantIndex: number; commentId: number; vote: number; pid: string }[] = [];
+
+ const voteGenerator = () => ([-1, 1, 0][Math.floor(Math.random() * 3)] as -1 | 0 | 1);
+
+ // Create comments first
+ for (let i = 0; i < numComments; i++) {
+ // Pass the result of the async getter
+ const commentId = await createComment(await getTestAgent(), conversationId, {
+ conversation_id: conversationId,
+ txt: `Test comment ${i + 1} created for data analysis`
+ });
+ comments.push(commentId);
+ }
+
+ // Create participants and their votes
+ for (let i = 0; i < numParticipants; i++) {
+ // Initialize participant
+ const { agent: participantAgent } = await initializeParticipant(conversationId);
+ participants.push(participantAgent);
+
+ let pid = 'mypid';
+
+ // Have each participant vote on all comments
+ for (let j = 0; j < comments.length; j++) {
+ const vote = voteGenerator();
+
+ const response = await submitVote(participantAgent, {
+ tid: comments[j],
+ conversation_id: conversationId,
+ vote: vote,
+ pid: pid
+ });
+
+ // Update pid for next vote
+ pid = response.body.currentPid || pid;
+
+ votes.push({
+ participantIndex: i,
+ commentId: comments[j],
+ vote: vote,
+ pid: pid
+ });
+ }
+ }
+
+ // Wait for data to be processed
+ await wait(2000);
+
+ return {
+ participants,
+ comments,
+ votes,
+ stats: {
+ numParticipants,
+ numComments,
+ totalVotes: votes.length
+ }
+ };
+}
+
+// Export API constants along with helper functions
+export {
+ authenticateAgent,
+ createComment,
+ createConversation,
+ createHmacSignature,
+ extractCookieValue,
+ generateRandomXid,
+ generateTestUser,
+ getMyVotes,
+ getTestAgent,
+ getTextAgent,
+ getVotes,
+ initializeParticipant,
+ initializeParticipantWithXid,
+ newAgent,
+ newTextAgent,
+ populateConversationWithVotes,
+ registerAndLoginUser,
+ setupAuthAndConvo,
+ submitVote,
+ updateConversation,
+ wait,
+ // Export Types needed by tests
+ AuthData,
+ CommentOptions,
+ ConversationOptions,
+ ConvoData,
+ ParticipantData,
+ TestUser,
+ ValidationOptions,
+ VoteData,
+ VoteResponse
+};
\ No newline at end of file
diff --git a/server/__tests__/setup/custom-jest-reporter.ts b/server/__tests__/setup/custom-jest-reporter.ts
new file mode 100644
index 0000000000..80d0104467
--- /dev/null
+++ b/server/__tests__/setup/custom-jest-reporter.ts
@@ -0,0 +1,98 @@
+/**
+ * Custom Jest Reporter
+ *
+ * This reporter adds a detailed summary of failed tests at the end of the test run.
+ */
+
+// Use CommonJS format since Jest's reporter system expects it
+module.exports = class CustomJestReporter {
+ constructor(globalConfig, options) {
+ this.globalConfig = globalConfig;
+ this.options = options || {};
+ this.failedSuites = new Map();
+ this.failedTests = 0;
+ this.passedTests = 0;
+ this.totalTests = 0;
+ }
+
+ onRunComplete(_contexts, results) {
+ this.totalTests = results.numTotalTests;
+ this.passedTests = results.numPassedTests;
+ this.failedTests = results.numFailedTests;
+
+ // If there are no failures, just print a nice message
+ if (results.numFailedTests === 0) {
+ if (results.numTotalTests > 0) {
+ // Optional success message could go here
+ }
+ return;
+ }
+
+ // Collect failed tests information
+ results.testResults.forEach((testResult) => {
+ const failedTestsInSuite = testResult.testResults.filter((test) => test.status === 'failed');
+
+ if (failedTestsInSuite.length > 0) {
+ this.failedSuites.set(
+ testResult.testFilePath,
+ failedTestsInSuite.map((test) => ({
+ name: test.fullName || test.title,
+ errorMessage: this.formatErrorMessage(test.failureMessages[0])
+ }))
+ );
+ }
+ });
+
+ this.printFailureSummary();
+ }
+
+ formatErrorMessage(errorMessage) {
+ if (!errorMessage) {
+ return 'Unknown error';
+ }
+
+ // Try to extract the most relevant part of the error message
+ const lines = errorMessage.split('\n');
+
+ // If it's an assertion error, get the comparison lines
+ const expectedLine = lines.find((line) => line.includes('Expected:'));
+ const receivedLine = lines.find((line) => line.includes('Received:'));
+
+ if (expectedLine && receivedLine) {
+ return `${expectedLine} ${receivedLine}`;
+ }
+
+ // Otherwise, return the first line that's likely the most informative
+ for (const line of lines) {
+ const trimmed = line.trim();
+ // Skip stack trace lines and empty lines
+ if (trimmed && !trimmed.startsWith('at ') && !trimmed.startsWith('Error:')) {
+ return trimmed;
+ }
+ }
+
+ // Fallback to first line
+ return lines[0] || 'Unknown error';
+ }
+
+ printFailureSummary() {
+ let testCounter = 0;
+
+ // Print each failed suite and its tests
+ this.failedSuites.forEach((tests, suitePath) => {
+ const relativePath = suitePath.replace(process.cwd(), '').replace(/^\//, '');
+ console.log(`\n\x1b[31m● Failed in: \x1b[1m${relativePath}\x1b[0m`);
+
+ tests.forEach((test) => {
+ testCounter++;
+ console.log(` \x1b[31m● ${test.name}\x1b[0m`);
+ console.log(` \x1b[90m${test.errorMessage}\x1b[0m`);
+ });
+ });
+
+ // Print summary
+ if (testCounter > 0) {
+ console.log(`\n\x1b[31m${testCounter} failing tests\x1b[0m`);
+ }
+ }
+};
\ No newline at end of file
diff --git a/server/__tests__/setup/db-test-helpers.ts b/server/__tests__/setup/db-test-helpers.ts
new file mode 100644
index 0000000000..68bc751036
--- /dev/null
+++ b/server/__tests__/setup/db-test-helpers.ts
@@ -0,0 +1,48 @@
+import dotenv from 'dotenv';
+import pg from 'pg';
+
+// Load environment variables from .env file but don't override command-line vars
+dotenv.config({ override: false });
+
+/**
+ * SECURITY CHECK: Prevent running tests against production databases
+ * This function checks if the DATABASE_URL contains indicators of a production database
+ * and will exit the process if a production database is detected.
+ */
+function preventProductionDatabaseTesting(): void {
+ const dbUrl = process.env.DATABASE_URL || '';
+ const productionIndicators = ['amazonaws', 'prod'];
+
+ for (const indicator of productionIndicators) {
+ if (dbUrl.toLowerCase().includes(indicator)) {
+ console.error('\x1b[31m%s\x1b[0m', '❌ CRITICAL SECURITY WARNING ❌');
+ console.error('\x1b[31m%s\x1b[0m', 'Tests appear to be targeting a PRODUCTION database!');
+ console.error('\x1b[31m%s\x1b[0m', 'Tests are being aborted to prevent data loss or corruption.');
+ // Exit with non-zero code to indicate error
+ process.exit(1);
+ }
+ }
+}
+
+// Run the security check immediately
+preventProductionDatabaseTesting();
+
+const { Pool } = pg;
+
+// Use host.docker.internal to connect to the host machine's PostgreSQL instance
+// This works when running tests from the host machine
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL || 'postgres://postgres:postgres@host.docker.internal:5432/polis-dev'
+});
+
+/**
+ * Close the database pool
+ */
+async function closePool(): Promise {
+ await pool.end();
+}
+
+export {
+ pool,
+ closePool
+};
\ No newline at end of file
diff --git a/server/__tests__/setup/email-helpers.ts b/server/__tests__/setup/email-helpers.ts
new file mode 100644
index 0000000000..c24b1b28fd
--- /dev/null
+++ b/server/__tests__/setup/email-helpers.ts
@@ -0,0 +1,226 @@
+import http from 'node:http';
+
+// Email interface types
+interface EmailRecipient {
+ address: string;
+ name?: string;
+}
+
+interface EmailObject {
+ id: string;
+ subject: string;
+ text: string;
+ html?: string;
+ to: EmailRecipient[];
+ from: EmailRecipient;
+ date: string;
+ time?: Date;
+ [key: string]: any;
+}
+
+interface FindEmailOptions {
+ timeout?: number;
+ interval?: number;
+ maxAttempts?: number;
+}
+
+interface PasswordResetResult {
+ url: string | null;
+ token: string | null;
+}
+
+// MailDev server settings
+const MAILDEV_HOST = process.env.MAILDEV_HOST || 'localhost';
+const MAILDEV_PORT = process.env.MAILDEV_PORT || 1080;
+
+/**
+ * Get all emails from the MailDev server
+ * @returns {Promise} Array of email objects
+ */
+async function getEmails(): Promise {
+ return new Promise((resolve, reject) => {
+ const options = {
+ hostname: MAILDEV_HOST,
+ port: MAILDEV_PORT,
+ path: '/email',
+ method: 'GET'
+ };
+
+ const req = http.request(options, (res) => {
+ let data = '';
+ res.on('data', (chunk) => {
+ data += chunk;
+ });
+ res.on('end', () => {
+ try {
+ const emails = JSON.parse(data) as EmailObject[];
+ resolve(emails);
+ } catch (e) {
+ if (e instanceof Error) {
+ reject(new Error(`Failed to parse email response: ${e.message}`));
+ } else {
+ reject(new Error('Failed to parse email response'));
+ }
+ }
+ });
+ });
+
+ req.on('error', (error) => {
+ reject(new Error(`Failed to fetch emails: ${error.message}`));
+ });
+
+ req.end();
+ });
+}
+
+/**
+ * Get a specific email by its ID
+ * @param {string} id - Email ID
+ * @returns {Promise} Email object
+ */
+async function getEmail(id: string): Promise {
+ return new Promise((resolve, reject) => {
+ const options = {
+ hostname: MAILDEV_HOST,
+ port: MAILDEV_PORT,
+ path: `/email/${id}`,
+ method: 'GET'
+ };
+
+ const req = http.request(options, (res) => {
+ let data = '';
+ res.on('data', (chunk) => {
+ data += chunk;
+ });
+ res.on('end', () => {
+ try {
+ const email = JSON.parse(data) as EmailObject;
+ resolve(email);
+ } catch (e) {
+ if (e instanceof Error) {
+ reject(new Error(`Failed to parse email response: ${e.message}`));
+ } else {
+ reject(new Error('Failed to parse email response'));
+ }
+ }
+ });
+ });
+
+ req.on('error', (error) => {
+ reject(new Error(`Failed to fetch email: ${error.message}`));
+ });
+
+ req.end();
+ });
+}
+
+/**
+ * Delete all emails from the MailDev server
+ * @returns {Promise}
+ */
+async function deleteAllEmails(): Promise {
+ return new Promise((resolve, reject) => {
+ const options = {
+ hostname: MAILDEV_HOST,
+ port: MAILDEV_PORT,
+ path: '/email/all',
+ method: 'DELETE'
+ };
+
+ const req = http.request(options, (res) => {
+ if (res.statusCode === 200) {
+ resolve();
+ } else {
+ reject(new Error(`Failed to delete emails: status ${res.statusCode}`));
+ }
+ });
+
+ req.on('error', (error) => {
+ reject(new Error(`Failed to delete emails: ${error.message}`));
+ });
+
+ req.end();
+ });
+}
+
+/**
+ * Find the most recent email sent to a specific recipient
+ * @param {string} recipient - Email address of the recipient
+ * @param {FindEmailOptions} options - Additional options
+ * @returns {Promise} Email object
+ */
+async function findEmailByRecipient(
+ recipient: string,
+ options: FindEmailOptions = {}
+): Promise {
+ const { timeout = 10000, interval = 1000, maxAttempts = 10 } = options;
+
+ const startTime = Date.now();
+ let attempts = 0;
+
+ while (Date.now() - startTime < timeout && attempts < maxAttempts) {
+ attempts++;
+
+ try {
+ const emails = await getEmails();
+ const targetEmail = emails.find((email) =>
+ email.to?.some((to) => to.address.toLowerCase() === recipient.toLowerCase())
+ );
+
+ if (targetEmail) {
+ return await getEmail(targetEmail.id);
+ }
+ } catch (error) {
+ console.warn(`Error fetching emails (attempt ${attempts}): ${error.message}`);
+ }
+
+ if (attempts < maxAttempts) {
+ await new Promise((resolve) => setTimeout(resolve, interval));
+ }
+ }
+
+ throw new Error(`No email found for recipient ${recipient} after ${attempts} attempts`);
+}
+
+/**
+ * Extract the password reset URL and token from an email
+ * @param {EmailObject} email - Email object from MailDev
+ * @returns {PasswordResetResult} Object with url and token properties or null values if not found
+ */
+function extractPasswordResetUrl(email: EmailObject): PasswordResetResult {
+ if (email?.text) {
+ let token: string | null = null;
+ let url: string | null = null;
+
+ const urlMatch = email.text.match(/(https?:\/\/[^\s]+pwreset\/([a-zA-Z0-9_-]+))/);
+
+ if (urlMatch?.[1]) {
+ url = urlMatch[1];
+ token = urlMatch[2];
+ }
+
+ return { url, token };
+ }
+
+ return { url: null, token: null };
+}
+
+/**
+ * Get the password reset URL for a specific recipient
+ * @param {string} recipient - Email address of the recipient
+ * @param {FindEmailOptions} options - Options for email fetching
+ * @returns {Promise} Object with url and token properties
+ */
+async function getPasswordResetUrl(recipient: string, options: FindEmailOptions = {}): Promise {
+ const email = await findEmailByRecipient(recipient, options);
+ const result = extractPasswordResetUrl(email);
+
+ if (!result.url) {
+ throw new Error('Password reset URL not found in email');
+ }
+
+ return result;
+}
+
+export { deleteAllEmails, findEmailByRecipient, getEmail, getEmails, getPasswordResetUrl };
+export type { EmailObject, EmailRecipient, FindEmailOptions, PasswordResetResult };
diff --git a/server/__tests__/setup/globalSetup.ts b/server/__tests__/setup/globalSetup.ts
new file mode 100644
index 0000000000..75969faf68
--- /dev/null
+++ b/server/__tests__/setup/globalSetup.ts
@@ -0,0 +1,96 @@
+/**
+ * Global setup for Jest tests
+ * This file is executed once before any test files are loaded
+ */
+import { AddressInfo } from 'net';
+import { getApp } from '../app-loader';
+import { newAgent, newTextAgent } from './api-test-helpers';
+import { deleteAllEmails } from './email-helpers';
+/**
+ * Create a simplified server object for testing
+ * This avoids actually binding to a port while still providing the server interface needed for tests
+ *
+ * @param port - The port number to use in the server address info
+ * @returns A minimal implementation of http.Server with just what we need for tests
+ */
+function createTestServer(port: number): import('http').Server {
+ const server = {
+ address: (): AddressInfo => ({ port, family: 'IPv4', address: '127.0.0.1' }),
+ close: (callback?: (err?: Error) => void) => {
+ if (callback) callback();
+ }
+ };
+ return server as import('http').Server;
+}
+
+export default async (): Promise => {
+ console.log('Starting global test setup...');
+
+ // Check if a server is already running and close it to avoid port conflicts
+ // Use type assertion for global access
+ if ((globalThis as any).__SERVER__) {
+ try {
+ await new Promise((resolve, reject) => { // Add reject
+ // Use type assertion for global access
+ (globalThis as any).__SERVER__.close((err?: Error) => { // Handle potential error
+ if (err) {
+ console.warn('Warning: Error closing existing server during setup:', err.message);
+ // Decide whether to reject or resolve even if close fails
+ // reject(err); // Option 1: Fail setup if closing fails
+ resolve(); // Option 2: Continue setup even if closing fails (might leave previous server lingering)
+ } else {
+ // Use type assertion for global access
+ console.log(`Closed existing test server on port ${(globalThis as any).__SERVER_PORT__}`);
+ resolve();
+ }
+ });
+ });
+ } catch (err) {
+ // Catch potential rejection from the promise
+ console.warn('Warning: Error closing existing server (caught promise rejection):', err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ // Use a test server since we're using the app instance directly
+ const port = 5001; // Use a consistent port for tests
+ const server = createTestServer(port);
+
+ console.log(`Test server started on port ${port}`);
+
+ // Store the server and port in global variables for tests to use
+ // Use type assertion for global access
+ (globalThis as any).__SERVER__ = server;
+ (globalThis as any).__SERVER_PORT__ = port;
+
+ // Create agents that use the app instance directly
+ // Only create new agents if they don't already exist
+ try {
+ // Initialize the app asynchronously, ensuring it's fully loaded
+ await getApp();
+
+ // Use type assertion for global access
+ if (!(globalThis as any).__TEST_AGENT__) {
+ (globalThis as any).__TEST_AGENT__ = await newAgent();
+ console.log('Created new global test agent');
+ }
+
+ // Use type assertion for global access
+ if (!(globalThis as any).__TEXT_AGENT__) {
+ (globalThis as any).__TEXT_AGENT__ = await newTextAgent();
+ console.log('Created new global text agent');
+ }
+ } catch (err) {
+ console.error('Error initializing app or agents:', err);
+ throw err;
+ }
+
+ // Clear any existing emails
+ await deleteAllEmails();
+
+ // Store the API URL with the dynamic port
+ // Use type assertion for global access
+ (globalThis as any).__API_URL__ = `http://localhost:${port}`;
+ (globalThis as any).__API_PREFIX__ = '/api/v3';
+
+ console.log('Global test setup completed');
+};
\ No newline at end of file
diff --git a/server/__tests__/setup/globalTeardown.ts b/server/__tests__/setup/globalTeardown.ts
new file mode 100644
index 0000000000..b51f7fdeb2
--- /dev/null
+++ b/server/__tests__/setup/globalTeardown.ts
@@ -0,0 +1,62 @@
+/**
+ * Global teardown for Jest tests
+ * This file is executed once after all test files have been run
+ */
+
+// Types are defined in types/jest-globals.d.ts, don't redeclare them here
+// Just use (globalThis as any).__SERVER__ etc. for typechecking
+
+export default async (): Promise => {
+ console.log('Starting global test teardown...');
+
+ // Close the server if it exists
+ // Use type assertion for global access
+ if ((globalThis as any).__SERVER__) {
+ try {
+ // Using a promise to ensure server is closed before continuing
+ await new Promise((resolve, reject) => { // Add reject
+ // Use type assertion for global access
+ (globalThis as any).__SERVER__.close((err?: Error) => { // Handle potential error
+ if (err) {
+ console.warn('Warning: Error closing server during teardown:', err.message);
+ // Decide whether to reject or resolve even if close fails
+ // reject(err); // Option 1: Fail teardown if closing fails
+ resolve(); // Option 2: Continue teardown even if closing fails
+ } else {
+ // Use type assertion for global access
+ console.log(`Test server on port ${(globalThis as any).__SERVER_PORT__} shut down`);
+ resolve();
+ }
+ });
+ });
+ // Use type assertion for global access
+ (globalThis as any).__SERVER__ = null;
+ (globalThis as any).__SERVER_PORT__ = null;
+ } catch (err) {
+ // Catch potential rejection from the promise
+ console.warn('Warning: Error during server cleanup (caught promise rejection):', err instanceof Error ? err.message : String(err));
+ }
+ }
+
+ // Clean up API URL globals
+ // Use type assertion for global access
+ (globalThis as any).__API_URL__ = null;
+ (globalThis as any).__API_PREFIX__ = null;
+
+ // Note: We're deliberately NOT clearing the agent instances
+ // This allows them to be reused across test suites
+ // global.__TEST_AGENT__ = null;
+ // global.__TEXT_AGENT__ = null;
+
+ // Close the database connection pool globally
+ try {
+ // Dynamically require db-test-helpers to avoid import issues if it uses the pool early
+ const dbHelpers = require('./db-test-helpers');
+ await dbHelpers.closePool();
+ console.log('Database connection pool closed globally.');
+ } catch (err) {
+ console.warn('Warning: Error closing database pool globally:', err instanceof Error ? err.message : String(err));
+ }
+
+ console.log('Global test teardown completed');
+};
\ No newline at end of file
diff --git a/server/__tests__/setup/jest.setup.ts b/server/__tests__/setup/jest.setup.ts
new file mode 100644
index 0000000000..dd08b26f3e
--- /dev/null
+++ b/server/__tests__/setup/jest.setup.ts
@@ -0,0 +1,66 @@
+import { exec } from 'child_process';
+import path from 'path';
+import { promisify } from 'util';
+import { beforeAll } from '@jest/globals';
+import dotenv from 'dotenv';
+
+// Use CommonJS __dirname and __filename
+const execAsync = promisify(exec);
+
+// Load environment variables from .env file but don't override command-line vars
+dotenv.config({ override: false });
+
+/**
+ * Secondary safety check to prevent tests from running against production databases
+ * This is a redundant check in case db-test-helpers.ts is not loaded first
+ */
+function preventProductionDatabaseTesting(): void {
+ const dbUrl = process.env.DATABASE_URL || '';
+
+ if (dbUrl.toLowerCase().includes('amazonaws') || dbUrl.toLowerCase().includes('prod')) {
+ console.error('\x1b[31m%s\x1b[0m', '❌ CRITICAL SECURITY WARNING ❌');
+ console.error('\x1b[31m%s\x1b[0m', 'Tests appear to be targeting a PRODUCTION database!');
+ console.error('\x1b[31m%s\x1b[0m', 'Tests are being aborted to prevent data loss or corruption.');
+ process.exit(1);
+ }
+}
+
+/**
+ * Reset the database by running the db-reset.js script
+ * This will be used when the RESET_DB_BEFORE_TESTS environment variable is set
+ */
+async function resetDatabase(): Promise {
+ console.log('\n🔄 Resetting database before tests...');
+
+ try {
+ const resetScript = path.join(__dirname, '..', '..', 'bin', 'db-reset.js');
+ const { stderr } = await execAsync(`node ${resetScript}`, {
+ env: { ...process.env, SKIP_CONFIRM: 'true' }
+ });
+
+ console.log('\n✅ Database reset complete!');
+
+ if (stderr) {
+ console.error('stderr:', stderr);
+ }
+ } catch (error) {
+ console.error('\n❌ Failed to reset database:', error);
+ throw error;
+ }
+}
+
+// Run the safety check before any tests
+preventProductionDatabaseTesting();
+
+// Increase timeout for all tests
+jest.setTimeout(60000);
+
+// Keep the reset logic if needed, but maybe move it to globalSetup?
+// For now, let's assume the check in globalSetup handles DB readiness implicitly via app load.
+// If RESET_DB_BEFORE_TESTS is needed, globalSetup might be a better place.
+if (process.env.RESET_DB_BEFORE_TESTS === 'true') {
+ beforeAll(async () => {
+ console.log('RESET_DB_BEFORE_TESTS=true detected in jest.setup.ts');
+ await resetDatabase();
+ }, 60000); // Give reset more time if needed
+}
\ No newline at end of file
diff --git a/server/__tests__/unit/app.test.ts b/server/__tests__/unit/app.test.ts
new file mode 100644
index 0000000000..15389d51d4
--- /dev/null
+++ b/server/__tests__/unit/app.test.ts
@@ -0,0 +1,14 @@
+import { describe, test, expect } from '@jest/globals';
+import { getApp } from '../app-loader';
+import express from 'express';
+
+describe('App Module', () => {
+ test('app should be an Express instance', async () => {
+ const app = await getApp();
+ expect(app).toBeDefined();
+ expect(app).toHaveProperty('use');
+ expect(app).toHaveProperty('get');
+ expect(app).toHaveProperty('post');
+ expect(app).toBeInstanceOf(Object);
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/unit/commentRoutes.test.ts b/server/__tests__/unit/commentRoutes.test.ts
new file mode 100644
index 0000000000..6b386e2e9d
--- /dev/null
+++ b/server/__tests__/unit/commentRoutes.test.ts
@@ -0,0 +1,155 @@
+import { beforeEach, describe, expect, test, jest } from '@jest/globals';
+import express, { Request, Response } from 'express';
+import request from 'supertest';
+
+// Mock types
+interface CommentCreateRequest {
+ conversation_id?: number;
+ txt?: string;
+}
+
+interface CommentResponse {
+ tid: number;
+ conversation_id: number;
+ txt: string;
+ created: number;
+}
+
+interface CommentTranslationsResponse {
+ translations: {
+ [lang: string]: string;
+ };
+}
+
+// Create mocks for the comment controller
+const mockHandleCreateComment = jest.fn((req: Request, res: Response) => {
+ const { conversation_id, txt } = req.body as CommentCreateRequest;
+ if (!conversation_id || !txt) {
+ return res.status(400).json({ error: 'Missing required fields' });
+ }
+ res.json({
+ tid: 123,
+ conversation_id,
+ txt,
+ created: new Date().getTime()
+ });
+});
+
+const mockHandleGetComments = jest.fn((req: Request, res: Response) => {
+ const { conversation_id } = req.query;
+ if (!conversation_id) {
+ return res.status(400).json({ error: 'Missing conversation_id' });
+ }
+ res.json([
+ {
+ tid: 123,
+ conversation_id: Number.parseInt(conversation_id as string, 10),
+ txt: 'Test comment 1',
+ created: new Date().getTime() - 1000
+ },
+ {
+ tid: 124,
+ conversation_id: Number.parseInt(conversation_id as string, 10),
+ txt: 'Test comment 2',
+ created: new Date().getTime()
+ }
+ ]);
+});
+
+const mockHandleGetCommentTranslations = jest.fn((req: Request, res: Response) => {
+ const { conversation_id, tid } = req.query;
+ if (!conversation_id || !tid) {
+ return res.status(400).json({ error: 'Missing required fields' });
+ }
+ res.json({
+ translations: {
+ en: 'English translation',
+ es: 'Spanish translation'
+ }
+ });
+});
+
+describe('Comment Routes', () => {
+ let app: express.Application;
+
+ beforeEach(() => {
+ app = express();
+ app.use(express.json());
+
+ // Reset mock implementations
+ mockHandleCreateComment.mockClear();
+ mockHandleGetComments.mockClear();
+ mockHandleGetCommentTranslations.mockClear();
+
+ // Set up routes directly on the app
+ app.post('/comments', mockHandleCreateComment);
+ app.get('/comments', mockHandleGetComments);
+ app.get('/comments/translations', mockHandleGetCommentTranslations);
+ });
+
+ describe('POST /comments', () => {
+ test('should create a comment when valid data is provided', async () => {
+ const commentData: CommentCreateRequest = {
+ conversation_id: 456,
+ txt: 'This is a test comment'
+ };
+
+ const response = await request(app).post('/comments').send(commentData);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveProperty('tid', 123);
+ expect(response.body).toHaveProperty('conversation_id', commentData.conversation_id);
+ expect(response.body).toHaveProperty('txt', commentData.txt);
+ expect(mockHandleCreateComment).toHaveBeenCalled();
+ });
+
+ test('should return 400 when required fields are missing', async () => {
+ const response = await request(app).post('/comments').send({});
+
+ expect(response.status).toBe(400);
+ expect(response.body).toHaveProperty('error', 'Missing required fields');
+ expect(mockHandleCreateComment).toHaveBeenCalled();
+ });
+ });
+
+ describe('GET /comments', () => {
+ test('should return comments for a conversation', async () => {
+ const response = await request(app).get('/comments?conversation_id=456');
+
+ expect(response.status).toBe(200);
+ expect(Array.isArray(response.body)).toBe(true);
+ expect(response.body).toHaveLength(2);
+ expect(response.body[0]).toHaveProperty('tid', 123);
+ expect(response.body[1]).toHaveProperty('tid', 124);
+ expect(mockHandleGetComments).toHaveBeenCalled();
+ });
+
+ test('should return 400 when conversation_id is missing', async () => {
+ const response = await request(app).get('/comments');
+
+ expect(response.status).toBe(400);
+ expect(response.body).toHaveProperty('error', 'Missing conversation_id');
+ expect(mockHandleGetComments).toHaveBeenCalled();
+ });
+ });
+
+ describe('GET /comments/translations', () => {
+ test('should return translations for a comment', async () => {
+ const response = await request(app).get('/comments/translations?conversation_id=456&tid=123');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toHaveProperty('translations');
+ expect(response.body.translations).toHaveProperty('en', 'English translation');
+ expect(response.body.translations).toHaveProperty('es', 'Spanish translation');
+ expect(mockHandleGetCommentTranslations).toHaveBeenCalled();
+ });
+
+ test('should return 400 when required fields are missing', async () => {
+ const response = await request(app).get('/comments/translations');
+
+ expect(response.status).toBe(400);
+ expect(response.body).toHaveProperty('error', 'Missing required fields');
+ expect(mockHandleGetCommentTranslations).toHaveBeenCalled();
+ });
+ });
+});
\ No newline at end of file
diff --git a/server/test/export.test.ts b/server/__tests__/unit/exportRoutes.test.ts
similarity index 77%
rename from server/test/export.test.ts
rename to server/__tests__/unit/exportRoutes.test.ts
index 19abe92ebd..4d12fb0b00 100644
--- a/server/test/export.test.ts
+++ b/server/__tests__/unit/exportRoutes.test.ts
@@ -6,14 +6,14 @@ import {
sendVotesSummary,
sendParticipantVotesSummary,
sendParticipantXidsSummary,
-} from "../src/routes/export";
-import { queryP_readOnly, stream_queryP_readOnly } from "../src/db/pg-query";
-import { getZinvite } from "../src/utils/zinvite";
-import { getPca } from "../src/utils/pca";
-import { getXids } from "../src/routes/math";
+} from "../../src/routes/export";
+import { queryP_readOnly, stream_queryP_readOnly } from "../../src/db/pg-query";
+import { getZinvite } from "../../src/utils/zinvite";
+import { getPca } from "../../src/utils/pca";
+import { getXids } from "../../src/routes/math";
import { jest } from "@jest/globals";
-import logger from "../src/utils/logger";
-import fail from "../src/utils/fail";
+import logger from "../../src/utils/logger";
+import fail from "../../src/utils/fail";
type Formatters = Record string>;
@@ -55,23 +55,23 @@ function mockStreamWithRows(rows: any[], shouldError = false, error?: Error) {
);
}
-jest.mock("../src/db/pg-query", () => ({
+jest.mock("../../src/db/pg-query", () => ({
queryP_readOnly: jest.fn(),
stream_queryP_readOnly: jest.fn(),
}));
-jest.mock("../src/utils/zinvite", () => ({
+jest.mock("../../src/utils/zinvite", () => ({
getZinvite: jest.fn(),
getZidForRid: jest.fn(),
}));
-jest.mock("../src/routes/math", () => ({
+jest.mock("../../src/routes/math", () => ({
getXids: jest.fn(),
}));
-jest.mock("../src/utils/pca");
-jest.mock("../src/utils/logger");
-jest.mock("../src/utils/fail");
+jest.mock("../../src/utils/pca");
+jest.mock("../../src/utils/logger");
+jest.mock("../../src/utils/fail");
describe("handle_GET_reportExport", () => {
let mockRes: MockResponse;
@@ -279,7 +279,7 @@ describe("handle_GET_reportExport", () => {
// Use the original require approach since it's more compatible with jest.spyOn
const formatDatetimeSpy = jest.spyOn(
// eslint-disable-next-line @typescript-eslint/no-var-requires
- require("../src/routes/export"),
+ require("../../src/routes/export"),
"formatDatetime"
);
formatDatetimeSpy.mockReturnValue(
@@ -446,5 +446,66 @@ describe("handle_GET_reportExport", () => {
expect(mockRes.end).toHaveBeenCalled();
});
+
+ it("sendParticipantVotesSummary should correctly map participants to groups when base-cluster IDs are not sequential", async () => {
+ const zid = 789;
+ const mockResNonSequential = createMockResponse();
+
+ // 1. Mock comment data (pgQueryP_readOnly)
+ (queryP_readOnly as jest.Mock).mockResolvedValueOnce([
+ { tid: 101, pid: 10 }, // Participant 10 authored comment 101
+ { tid: 102, pid: 20 }, // Participant 20 authored comment 102
+ { tid: 103, pid: 30 }, // Participant 30 authored comment 103 (will be in-conv but no group)
+ { tid: 104, pid: 40 }, // Participant 40 authored comment 104 (not in PCA)
+ ] as never);
+
+ // 2. Mock PCA data (getPca)
+ const pcaDataWithNonSequentialBaseClusterIds = {
+ "in-conv": [10, 20, 30], // Participants 10, 20, 30 are in the conversation
+ "base-clusters": {
+ members: [
+ [10], // Base cluster at index 0 (ID 55) has participant 10
+ [20], // Base cluster at index 1 (ID 66) has participant 20
+ /* Participant 30 is in-conv but not in any base-cluster members list here to test that path */
+ ],
+ x: [0.1, 0.2],
+ y: [0.1, 0.2],
+ id: [55, 66], // Actual base cluster IDs are 55 and 66 (non-sequential with index)
+ count: [1, 1]
+ },
+ "group-clusters": [
+ { id: 7, center: [0.1, 0.1], members: [55] }, // Group 7 contains base cluster 55
+ { id: 8, center: [0.2, 0.2], members: [66] }, // Group 8 contains base cluster 66
+ ],
+ "user-vote-counts": { 10: 1, 20: 1, 30: 1 }
+ };
+ (getPca as jest.Mock).mockResolvedValue({
+ asPOJO: pcaDataWithNonSequentialBaseClusterIds,
+ } as never);
+
+ // 3. Mock votes data (stream_queryP_readOnly)
+ mockStreamWithRows([
+ { pid: 10, tid: 101, vote: -1 }, // Participant 10 voted on their comment (agree)
+ { pid: 20, tid: 102, vote: 1 }, // Participant 20 voted on their comment (disagree)
+ { pid: 30, tid: 103, vote: -1} // Participant 30 voted on their comment (agree)
+ ]);
+
+ await sendParticipantVotesSummary(zid, mockResNonSequential as any);
+
+ expect(mockResNonSequential.setHeader).toHaveBeenCalledWith("content-type", "text/csv");
+ // Header should now include all tids from the mocked queryP_readOnly call for comments
+ expect(mockResNonSequential.write).toHaveBeenCalledWith(
+ "participant,group-id,n-comments,n-votes,n-agree,n-disagree,101,102,103,104\n"
+ );
+
+ // Participant 10 (pid 10) should be in group 7
+ expect(mockResNonSequential.write).toHaveBeenCalledWith("10,7,1,1,1,0,1,,,\n");
+ // Participant 20 (pid 20) should be in group 8. Vote was 1, flipped to -1.
+ expect(mockResNonSequential.write).toHaveBeenCalledWith("20,8,1,1,0,1,,-1,,\n");
+ // Participant 30 (pid 30) is in-conv but not in a base-cluster that maps to a group cluster (or not in base-cluster.members)
+ // It has 1 comment, 1 vote (agree, which is -1, flipped to 1)
+ expect(mockResNonSequential.write).toHaveBeenCalledWith("30,,1,1,1,0,,,1,\n");
+ expect(mockResNonSequential.end).toHaveBeenCalled();
+ });
});
});
diff --git a/server/__tests__/unit/healthRoutes.test.ts b/server/__tests__/unit/healthRoutes.test.ts
new file mode 100644
index 0000000000..9a03bd046d
--- /dev/null
+++ b/server/__tests__/unit/healthRoutes.test.ts
@@ -0,0 +1,49 @@
+import { beforeEach, describe, expect, test } from '@jest/globals';
+import express, { Request, Response } from 'express';
+import request from 'supertest';
+
+// Create a mock for the health controller
+const mockHandleGetTestConnection = (_req: Request, res: Response): void => {
+ res.json({ status: 'ok', message: 'API is running' });
+};
+
+const mockHandleGetTestDatabase = (_req: Request, res: Response): void => {
+ res.json({ status: 'ok', message: 'Database connection successful' });
+};
+
+describe('Health Routes', () => {
+ let app: express.Application;
+
+ beforeEach(() => {
+ app = express();
+ app.use(express.json());
+
+ // Set up routes directly on the app
+ app.get('/testConnection', mockHandleGetTestConnection);
+ app.get('/testDatabase', mockHandleGetTestDatabase);
+ });
+
+ describe('GET /testConnection', () => {
+ test('should return a 200 status and confirm API is running', async () => {
+ const response = await request(app).get('/testConnection');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ status: 'ok',
+ message: 'API is running'
+ });
+ });
+ });
+
+ describe('GET /testDatabase', () => {
+ test('should return a 200 status and confirm database connection', async () => {
+ const response = await request(app).get('/testDatabase');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({
+ status: 'ok',
+ message: 'Database connection successful'
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/server/__tests__/unit/simpleTest.ts b/server/__tests__/unit/simpleTest.ts
new file mode 100644
index 0000000000..3addb31434
--- /dev/null
+++ b/server/__tests__/unit/simpleTest.ts
@@ -0,0 +1,11 @@
+import { describe, expect, test } from '@jest/globals';
+
+describe('Simple Test Suite', () => {
+ test('basic test', () => {
+ expect(1 + 1).toBe(2);
+ });
+
+ test('string concatenation', () => {
+ expect('hello' + ' ' + 'world').toBe('hello world');
+ });
+});
\ No newline at end of file
diff --git a/server/app.ts b/server/app.ts
index c5a1dffe7a..859ed71ed3 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -1517,6 +1517,10 @@ helpersInitialized.then(
/^\/narrativeReport\/r?[0-9][0-9A-Za-z]+(\/.*)?/,
fetchIndexForReportPage
);
+ app.get(
+ /^\/stats\/r?[0-9][0-9A-Za-z]+(\/.*)?/,
+ fetchIndexForReportPage
+ );
app.get(/^\/thirdPartyCookieTestPt1\.html$/, fetchThirdPartyCookieTestPt1);
app.get(/^\/thirdPartyCookieTestPt2\.html$/, fetchThirdPartyCookieTestPt2);
@@ -1620,8 +1624,9 @@ helpersInitialized.then(
app.get(/^\/[^(api\/)]?.*/, proxy);
}
- app.listen(Config.serverPort);
- logger.info("started on port " + Config.serverPort);
+ // move app.listen to index.ts
+ // app.listen(Config.serverPort);
+ // logger.info("started on port " + Config.serverPort);
},
function (err) {
diff --git a/server/babel.config.js b/server/babel.config.js
new file mode 100644
index 0000000000..399ce77474
--- /dev/null
+++ b/server/babel.config.js
@@ -0,0 +1,5 @@
+module.exports = {
+ presets: [
+ ['@babel/preset-env', { targets: { node: 'current' } }]
+ ]
+};
\ No newline at end of file
diff --git a/server/bin/db-reset.js b/server/bin/db-reset.js
new file mode 100644
index 0000000000..18f7e0af11
--- /dev/null
+++ b/server/bin/db-reset.js
@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+
+/**
+ * Database Reset Script
+ *
+ * This script will:
+ * 1. Check that we're not targeting a production database
+ * 2. Drop and recreate the database specified in DATABASE_URL
+ * 3. Run all migrations on the fresh database
+ *
+ * IMPORTANT: This will delete all data in the target database!
+ * Make sure your DATABASE_URL points to a test/development database.
+ */
+
+import { exec } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { promisify } from 'util';
+import dotenv from 'dotenv';
+import pg from 'pg';
+
+// Setup dirname for ES modules
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const execAsync = promisify(exec);
+
+// Load environment variables
+dotenv.config();
+
+const databaseUrl = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/polis-dev';
+const skipConfirm = process.env.SKIP_CONFIRM === 'true';
+
+/**
+ * Safety check to prevent resetting production databases
+ */
+function isSafeDatabase(dbUrl) {
+ if (!dbUrl) {
+ console.error('\x1b[31m%s\x1b[0m', '❌ Error: No DATABASE_URL provided.');
+ return false;
+ }
+
+ // Check for indicators of a production database
+ const productionIndicators = ['amazonaws', 'prod'];
+ const lowercaseUrl = dbUrl.toLowerCase();
+
+ for (const indicator of productionIndicators) {
+ if (lowercaseUrl.includes(indicator)) {
+ console.error('\x1b[31m%s\x1b[0m', '❌ CRITICAL SECURITY WARNING ❌');
+ console.error('\x1b[31m%s\x1b[0m', 'This script will NOT execute on a PRODUCTION database!');
+ console.error(
+ '\x1b[31m%s\x1b[0m',
+ `DATABASE_URL contains "${indicator}", which suggests a production environment.`
+ );
+ console.error('\x1b[31m%s\x1b[0m', 'Please check your DATABASE_URL and try again with a development database.');
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Parse database connection info from URL
+ */
+function parseDatabaseUrl(dbUrl) {
+ // Extract user, password, host, port, database from URL
+ // Format: postgres://username:password@host:port/database
+ const match = dbUrl.match(/postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
+
+ if (!match) {
+ throw new Error('Invalid DATABASE_URL format');
+ }
+
+ return {
+ username: match[1],
+ password: match[2],
+ host: match[3],
+ port: match[4],
+ database: match[5]
+ };
+}
+
+/**
+ * Main function to reset the database
+ */
+async function resetDatabase() {
+ console.log('\x1b[34m%s\x1b[0m', '🔄 Starting database reset process...');
+
+ // Safety check
+ if (!isSafeDatabase(databaseUrl)) {
+ process.exit(1);
+ }
+
+ // Parse connection details
+ const dbConfig = parseDatabaseUrl(databaseUrl);
+ console.log(`📊 Target database: ${dbConfig.database} on ${dbConfig.host}`);
+
+ try {
+ // Setup connection to PostgreSQL server (not the target database)
+ const connectionString = `postgres://${dbConfig.username}:${dbConfig.password}@${dbConfig.host}:${dbConfig.port}/postgres`;
+ const client = new pg.Client(connectionString);
+ await client.connect();
+
+ if (!skipConfirm) {
+ console.log('\x1b[33m%s\x1b[0m', '⚠️ WARNING: All data in the database will be lost!');
+ console.log('\x1b[33m%s\x1b[0m', '⚠️ DATABASE_URL:', databaseUrl);
+ console.log('\x1b[33m%s\x1b[0m', '⚠️ You have 5 seconds to cancel (Ctrl+C)...');
+
+ // Wait 5 seconds to give user a chance to cancel
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ } else {
+ console.log('Skipping confirmation due to SKIP_CONFIRM=true');
+ }
+
+ // Drop database if it exists
+ console.log(`🗑️ Dropping database "${dbConfig.database}" if it exists...`);
+ await client.query(`DROP DATABASE IF EXISTS "${dbConfig.database}" WITH (FORCE);`);
+
+ // Create fresh database
+ console.log(`🆕 Creating new database "${dbConfig.database}"...`);
+ await client.query(`CREATE DATABASE "${dbConfig.database}";`);
+
+ // Close connection to postgres database
+ await client.end();
+
+ // Get list of migration files
+ const migrationsDir = path.join(__dirname, '..', 'postgres', 'migrations');
+ const migrationFiles = fs
+ .readdirSync(migrationsDir)
+ .filter((file) => file.endsWith('.sql'))
+ .filter((file) => !file.includes('archived'))
+ .sort();
+
+ // Apply each migration
+ console.log('🔄 Applying migrations...');
+
+ for (const migrationFile of migrationFiles) {
+ console.log(` ➡️ Applying ${migrationFile}...`);
+ const migrationPath = path.join(migrationsDir, migrationFile);
+
+ // Use psql to apply the migration
+ const { _stdout, stderr } = await execAsync(
+ `PGPASSWORD="${dbConfig.password}" psql -h ${dbConfig.host} -p ${dbConfig.port} -U ${dbConfig.username} -d ${dbConfig.database} -f "${migrationPath}"`
+ );
+
+ if (stderr && !stderr.includes('NOTICE')) {
+ console.warn(` ⚠️ Warnings: ${stderr}`);
+ }
+ }
+
+ console.log('\x1b[32m%s\x1b[0m', '✅ Database reset complete!');
+ console.log(`📁 Applied ${migrationFiles.length} migrations`);
+ console.log('\x1b[32m%s\x1b[0m', '✨ Your database is fresh and ready to use!');
+ } catch (error) {
+ console.error('\x1b[31m%s\x1b[0m', '❌ Error resetting database:');
+ console.error(error);
+ process.exit(1);
+ }
+}
+
+// Run the reset process
+resetDatabase();
diff --git a/server/index.ts b/server/index.ts
new file mode 100644
index 0000000000..ad8eca114d
--- /dev/null
+++ b/server/index.ts
@@ -0,0 +1,23 @@
+/**
+ * Server entry point
+ * This file is responsible for starting the server after the app is configured
+ */
+import app from "./app";
+import Config from "./src/config";
+import logger from "./src/utils/logger";
+
+/**
+ * Start the server on the configured port or a provided port
+ * @param {number} [port=Config.serverPort] - The port to listen on
+ * @returns {Object} The server instance
+ */
+function startServer(port = Config.serverPort) {
+ const server = app.listen(port);
+ logger.info(`Server started on port ${port}`);
+ return server;
+}
+
+startServer();
+
+export { startServer };
+export default app;
diff --git a/server/jest.config.ts b/server/jest.config.ts
index 059de95606..2b3069a1e4 100644
--- a/server/jest.config.ts
+++ b/server/jest.config.ts
@@ -1,10 +1,31 @@
-import type { Config } from "jest";
-
-const config: Config = {
- preset: "ts-jest",
- setupFiles: ["/test/settings/env-setup.ts"],
- testEnvironment: "node",
- testPathIgnorePatterns: ["/node_modules/", "/dist/"],
+export default {
+ transform: {
+ '^.+\\.(ts|tsx)$': ['ts-jest', {
+ tsconfig: './tsconfig.json',
+ }],
+ '^.+\\.(js|jsx)$': ['babel-jest', {
+ presets: ['@babel/preset-env'],
+ }],
+ },
+ moduleNameMapper: {
+ '^(\\.{1,2}/.*)\\.js$': '$1'
+ },
+ extensionsToTreatAsEsm: ['.ts', '.tsx'],
+ testEnvironment: 'node',
+ testMatch: ['**/__tests__/**/*.test.ts'],
+ // Exclude patterns for tests that shouldn't be directly run
+ testPathIgnorePatterns: ['/__tests__/feature/', '/dist/', '/__tests__/app-loader.ts', '/__tests__/setup/'],
+ collectCoverage: true,
+ coverageDirectory: 'coverage',
+ collectCoverageFrom: ['app.ts', 'src/**/*.{js,ts}', '!src/**/*.test.{js,ts}', '!**/node_modules/**'],
+ coverageReporters: ['lcov', 'clover', 'html'],
+ // detectOpenHandles: true,
+ forceExit: true,
+ verbose: true,
+ setupFilesAfterEnv: ['./__tests__/setup/jest.setup.ts'],
+ // Custom reporter provides better error reporting
+ reporters: ['default', './__tests__/setup/custom-jest-reporter.ts'],
+ // Add global setup and teardown files
+ globalSetup: './__tests__/setup/globalSetup.ts',
+ globalTeardown: './__tests__/setup/globalTeardown.ts'
};
-
-export default config;
diff --git a/server/mise.toml b/server/mise.toml
new file mode 100644
index 0000000000..377ec7cc42
--- /dev/null
+++ b/server/mise.toml
@@ -0,0 +1,2 @@
+[tools]
+node = "lts"
diff --git a/server/package-lock.json b/server/package-lock.json
index 3bb6f1b7bf..ad1045285a 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -77,7 +77,7 @@
"@types/connect-timeout": "~0.0.34",
"@types/express": "~4.17.11",
"@types/http-proxy": "~1.17.5",
- "@types/jest": "^29.4.0",
+ "@types/jest": "^29.5.14",
"@types/lru-cache": "~5.1.0",
"@types/node": "^22.13.10",
"@types/nodemailer": "~6.4.1",
@@ -88,18 +88,19 @@
"@types/request-promise": "4.1.48",
"@types/response-time": "~2.3.4",
"@types/source-map-support": "~0.5.6",
- "@types/supertest": "^2.0.12",
+ "@types/supertest": "^6.0.3",
"@types/underscore": "~1.11.1",
"@types/valid-url": "~1.0.3",
"@typescript-eslint/eslint-plugin": "^5.50.0",
"@typescript-eslint/parser": "^5.50.0",
"eslint": "^8.33.0",
- "eslint-plugin-jest": "^27.2.1",
- "jest": "^29.4.1",
+ "eslint-plugin-jest": "^27.9.0",
+ "globals": "^16.0.0",
+ "jest": "^29.7.0",
"nodemon": "~2.0.20",
"prettier": "~2.2.1",
- "supertest": "^6.3.3",
- "ts-jest": "^29.0.5",
+ "supertest": "^7.1.0",
+ "ts-jest": "^29.3.1",
"typescript": "^5.7.2"
}
},
@@ -1561,6 +1562,15 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/plugin-transform-classes/node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/@babel/plugin-transform-computed-properties": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz",
@@ -2299,6 +2309,15 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/traverse/node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/@babel/types": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
@@ -2982,6 +3001,7 @@
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
+ "license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -3070,6 +3090,7 @@
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
"integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
@@ -4324,12 +4345,14 @@
}
},
"node_modules/@types/supertest": {
- "version": "2.0.16",
- "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.16.tgz",
- "integrity": "sha512-6c2ogktZ06tr2ENoZivgm7YnprnhYE4ZoXGMY+oA7IuAf17M8FWvujXZGmxLv8y0PTyts4x5A+erSwVUFA8XSg==",
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz",
+ "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "@types/superagent": "*"
+ "@types/methods": "^1.1.4",
+ "@types/superagent": "^8.1.0"
}
},
"node_modules/@types/tough-cookie": {
@@ -4792,7 +4815,8 @@
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/asn1": {
"version": "0.2.6",
@@ -5504,6 +5528,7 @@
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"dev": true,
+ "license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
@@ -5927,7 +5952,8 @@
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/core-js-compat": {
"version": "3.42.0",
@@ -6197,6 +6223,7 @@
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"asap": "^2.0.0",
"wrappy": "1"
@@ -7098,7 +7125,8 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "4.4.1",
@@ -7331,15 +7359,18 @@
}
},
"node_modules/formidable": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz",
- "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==",
+ "version": "3.5.4",
+ "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
+ "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"dezalgo": "^1.0.4",
- "once": "^1.4.0",
- "qs": "^6.11.0"
+ "once": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
},
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
@@ -7607,11 +7638,16 @@
}
},
"node_modules/globals": {
- "version": "11.12.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
- "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "version": "16.1.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz",
+ "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==",
+ "dev": true,
+ "license": "MIT",
"engines": {
- "node": ">=4"
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby": {
@@ -11737,25 +11773,24 @@
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw=="
},
"node_modules/superagent": {
- "version": "8.1.2",
- "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz",
- "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==",
- "deprecated": "Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net",
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz",
+ "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"component-emitter": "^1.3.0",
"cookiejar": "^2.1.4",
"debug": "^4.3.4",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.0",
- "formidable": "^2.1.2",
+ "formidable": "^3.5.1",
"methods": "^1.1.2",
"mime": "2.6.0",
- "qs": "^6.11.0",
- "semver": "^7.3.8"
+ "qs": "^6.11.0"
},
"engines": {
- "node": ">=6.4.0 <13 || >=14"
+ "node": ">=14.18.0"
}
},
"node_modules/superagent/node_modules/mime": {
@@ -11763,6 +11798,7 @@
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
+ "license": "MIT",
"bin": {
"mime": "cli.js"
},
@@ -11770,29 +11806,18 @@
"node": ">=4.0.0"
}
},
- "node_modules/superagent/node_modules/semver": {
- "version": "7.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
- "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
- "dev": true,
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/supertest": {
- "version": "6.3.4",
- "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz",
- "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.0.tgz",
+ "integrity": "sha512-5QeSO8hSrKghtcWEoPiO036fxH0Ii2wVQfFZSP0oqQhmjk8bOLhDFXr4JrvaFmPuEWUoq4znY3uSi8UzLKxGqw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"methods": "^1.1.2",
- "superagent": "^8.1.2"
+ "superagent": "^9.0.1"
},
"engines": {
- "node": ">=6.4.0"
+ "node": ">=14.18.0"
}
},
"node_modules/supports-color": {
diff --git a/server/package.json b/server/package.json
index d1b1b65565..d6e6852393 100644
--- a/server/package.json
+++ b/server/package.json
@@ -2,12 +2,12 @@
"name": "polis",
"version": "0.0.0",
"description": "polis =====",
- "main": "./dist/app.js",
+ "main": "./dist/index.js",
"scripts": {
"build": "tsc",
- "build:watch": "tsc --watch & nodemon --inspect=0.0.0.0:9229 dist/app.js",
- "debug": "SERVER_LOG_LEVEL=debug nodemon --inspect=0.0.0.0:9229 dist/app.js",
- "serve": "node --max_old_space_size=2048 --gc_interval=100 dist/app.js",
+ "build:watch": "tsc --watch & nodemon --inspect=0.0.0.0:9229 dist/index.js",
+ "debug": "SERVER_LOG_LEVEL=debug nodemon --inspect=0.0.0.0:9229 dist/index.js",
+ "serve": "node --max_old_space_size=2048 --gc_interval=100 dist/index.js",
"start": "npm run build && npm run serve",
"dev": "npm install && npm run build:watch",
"type:check:watch": "tsc --watch",
@@ -15,8 +15,11 @@
"format:check": "prettier --config ./prettier.config.js --no-editorconfig 'src/**/*.{js,ts}' --check",
"lint": "eslint --quiet .",
"lint:verbose": "eslint .",
- "test": "jest --forceExit test",
- "test:watch": "npm test -- --watchAll"
+ "db:reset": "node bin/db-reset.js",
+ "test": "jest",
+ "test:unit": "npm test -- /__tests__/unit/",
+ "test:integration": "npm test -- /__tests__/integration/",
+ "test:feature": "npm test -- /__tests__/feature/ --testPathIgnorePatterns=[]"
},
"repository": {
"type": "git",
@@ -94,7 +97,7 @@
"@types/connect-timeout": "~0.0.34",
"@types/express": "~4.17.11",
"@types/http-proxy": "~1.17.5",
- "@types/jest": "^29.4.0",
+ "@types/jest": "^29.5.14",
"@types/lru-cache": "~5.1.0",
"@types/node": "^22.13.10",
"@types/nodemailer": "~6.4.1",
@@ -105,18 +108,19 @@
"@types/request-promise": "4.1.48",
"@types/response-time": "~2.3.4",
"@types/source-map-support": "~0.5.6",
- "@types/supertest": "^2.0.12",
+ "@types/supertest": "^6.0.3",
"@types/underscore": "~1.11.1",
"@types/valid-url": "~1.0.3",
"@typescript-eslint/eslint-plugin": "^5.50.0",
"@typescript-eslint/parser": "^5.50.0",
"eslint": "^8.33.0",
- "eslint-plugin-jest": "^27.2.1",
- "jest": "^29.4.1",
+ "eslint-plugin-jest": "^27.9.0",
+ "globals": "^16.0.0",
+ "jest": "^29.7.0",
"nodemon": "~2.0.20",
"prettier": "~2.2.1",
- "supertest": "^6.3.3",
- "ts-jest": "^29.0.5",
+ "supertest": "^7.1.0",
+ "ts-jest": "^29.3.1",
"typescript": "^5.7.2"
}
-}
\ No newline at end of file
+}
diff --git a/server/src/comment.ts b/server/src/comment.ts
index 436a86ddd3..b8dede3ed4 100644
--- a/server/src/comment.ts
+++ b/server/src/comment.ts
@@ -160,7 +160,7 @@ function _getCommentsForModerationList(o: {
let adp: { [key: string]: Row } = {};
for (let i = 0; i < rows.length; i++) {
let row = rows[i];
- let o = (adp[row.tid] = adp[row.tid] || {
+ let o = (adp[row.tid] = adp[row.tid] || { tid: row.tid, vote: 0, count: 0,
agree_count: 0,
disagree_count: 0,
pass_count: 0,
diff --git a/server/src/db/pg-query.ts b/server/src/db/pg-query.ts
index 547048f114..661e2446c3 100644
--- a/server/src/db/pg-query.ts
+++ b/server/src/db/pg-query.ts
@@ -59,8 +59,9 @@ const readsPgConnection = Object.assign(
// pressure down on the transactor (read+write) server
// const PoolConstructor = pgnative?.Pool ?? Pool;
-const readWritePool: Pool = new Pool(pgConnection as PoolConfig);
-const readPool: Pool = new Pool(readsPgConnection as PoolConfig);
+// Cast to unknown first to avoid type errors with port being string vs number
+const readWritePool: Pool = new Pool(pgConnection as unknown as PoolConfig);
+const readPool: Pool = new Pool(readsPgConnection as unknown as PoolConfig);
// Same syntax as pg.client.query, but uses connection pool
// Also takes care of calling 'done'.
diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts
index e1093125f0..9b8eff975a 100644
--- a/server/src/routes/export.ts
+++ b/server/src/routes/export.ts
@@ -276,17 +276,17 @@ export async function sendParticipantVotesSummary(zid: number, res: Response) {
const pca = await getPca(zid);
// Define the getGroupId function
- function getGroupId(pca: { asPOJO: any } | undefined, pid: number): number | undefined {
+ function getGroupId(pca: { asPOJO: PcaData } | undefined, pid: number): number | undefined {
if (!pca || !pca.asPOJO) {
return undefined;
}
- const pcaData = pca.asPOJO as PcaData;
+ const pcaData = pca.asPOJO;
// Check if participant is in the conversation
const inConv = pcaData["in-conv"];
if (!inConv || !Array.isArray(inConv) || !inConv.includes(pid)) {
- logger.info(`Participant ${pid} not found in in-conv array`);
+ // Participant not in PCA, so legitimately has no group
return undefined;
}
@@ -294,33 +294,40 @@ export async function sendParticipantVotesSummary(zid: number, res: Response) {
const baseClusters = pcaData["base-clusters"];
const groupClusters = pcaData["group-clusters"];
- if (!baseClusters || !baseClusters.members || !Array.isArray(baseClusters.members)) {
- logger.info(`No base clusters found in PCA data`);
+ if (!baseClusters || !baseClusters.members || !Array.isArray(baseClusters.members) || !baseClusters.id || !Array.isArray(baseClusters.id)) {
+ logger.warn(`Incomplete base-clusters data in PCA for zid while processing pid ${pid}.`);
return undefined;
}
if (!groupClusters || !Array.isArray(groupClusters) || groupClusters.length === 0) {
- logger.info(`No group clusters found in PCA data`);
+ logger.warn(`No group-clusters array found or empty in PCA data for zid while processing pid ${pid}.`);
return undefined;
}
- // Step 1: Find which base cluster contains the participant
- let baseClusterId = -1;
+ // Step 1: Find which base cluster (by index) contains the participant
+ let baseClusterIndex = -1;
for (let i = 0; i < baseClusters.members.length; i++) {
- const members = baseClusters.members[i];
- if (Array.isArray(members) && members.includes(pid)) {
- baseClusterId = i;
+ const membersInBaseCluster = baseClusters.members[i];
+ if (Array.isArray(membersInBaseCluster) && membersInBaseCluster.includes(pid)) {
+ baseClusterIndex = i;
break;
}
}
- if (baseClusterId === -1) {
- // We couldn't find the participant in any base cluster
- logger.info(`Could not find base cluster for participant ${pid}`);
+ if (baseClusterIndex === -1) {
+ // Participant is "in-conv" but not found in any base cluster's member list.
+ logger.info(`Participant ${pid} (in-conv) not found in any base-cluster's members list.`);
return undefined;
}
- // Step 2: Find which group cluster contains this base cluster
+ // Retrieve the actual ID of the found base cluster
+ if (baseClusterIndex >= baseClusters.id.length) {
+ logger.warn(`Base cluster index ${baseClusterIndex} is out of bounds for baseClusters.id array (length ${baseClusters.id.length}) for pid ${pid}.`);
+ return undefined;
+ }
+ const baseClusterId = baseClusters.id[baseClusterIndex];
+
+ // Step 2: Find which group cluster contains this baseClusterId
for (const groupCluster of groupClusters) {
if (groupCluster.members && Array.isArray(groupCluster.members) &&
groupCluster.members.includes(baseClusterId)) {
@@ -328,8 +335,8 @@ export async function sendParticipantVotesSummary(zid: number, res: Response) {
}
}
- // We couldn't find the participant in any group cluster
- logger.info(`Could not find group cluster for participant ${pid}`);
+ // Participant was in a base cluster, but that base cluster ID was not found in any group cluster's members list.
+ logger.info(`Participant ${pid} in base_cluster_id ${baseClusterId}, but this base_cluster_id was not found in any group_cluster.members list.`);
return undefined;
}
diff --git a/server/src/routes/reportNarrative.ts b/server/src/routes/reportNarrative.ts
index 39dae3326f..1930fa6055 100644
--- a/server/src/routes/reportNarrative.ts
+++ b/server/src/routes/reportNarrative.ts
@@ -263,8 +263,8 @@ const getModelResponse = async (
},
],
});
- // @ts-expect-error claude api
- return `{${responseClaude?.content[0]?.text}`;
+ // Claude API response structure might change with version updates
+ return `{${(responseClaude as any)?.content[0]?.text}`;
}
case "openai": {
if (!openai) {
@@ -338,8 +338,8 @@ export async function handle_GET_groupInformedConsensus(
const cachedResponse = await storage?.queryItemsByRidSectionModel(
`${rid}#${section.name}#${model}`
);
- // @ts-expect-error function args ignore temp
- const structured_comments = await getCommentsAsXML(zid, section.filter);
+ // Use type assertion for filter function with different parameter shape but compatible runtime behavior
+ const structured_comments = await getCommentsAsXML(zid, section.filter as any);
// send cached response first if avalable
if (Array.isArray(cachedResponse) && cachedResponse?.length) {
res.write(
@@ -399,8 +399,8 @@ export async function handle_GET_groupInformedConsensus(
}) + `|||`
);
}
- // @ts-expect-error flush - calling due to use of compression
- res.flush();
+ // Express response has no flush method, but compression middleware adds it
+ (res as any).flush();
}
export async function handle_GET_uncertainty(
@@ -422,8 +422,8 @@ export async function handle_GET_uncertainty(
const cachedResponse = await storage?.queryItemsByRidSectionModel(
`${rid}#${section.name}#${model}`
);
- // @ts-expect-error function args ignore temp
- const structured_comments = await getCommentsAsXML(zid, section.filter);
+ // Use type assertion for filter function with different parameter shape but compatible runtime behavior
+ const structured_comments = await getCommentsAsXML(zid, section.filter as any);
// send cached response first if avalable
if (Array.isArray(cachedResponse) && cachedResponse?.length) {
res.write(
@@ -483,8 +483,8 @@ export async function handle_GET_uncertainty(
}) + `|||`
);
}
- // @ts-expect-error flush - calling due to use of compression
- res.flush();
+ // Express response has no flush method, but compression middleware adds it
+ (res as any).flush();
}
export async function handle_GET_groups(
@@ -507,8 +507,8 @@ export async function handle_GET_groups(
const cachedResponse = await storage?.queryItemsByRidSectionModel(
`${rid}#${section.name}#${model}`
);
- // @ts-expect-error function args ignore temp
- const structured_comments = await getCommentsAsXML(zid, section.filter);
+ // Use type assertion for filter function with different parameter shape but compatible runtime behavior
+ const structured_comments = await getCommentsAsXML(zid, section.filter as any);
// send cached response first if avalable
if (Array.isArray(cachedResponse) && cachedResponse?.length) {
res.write(
@@ -568,8 +568,8 @@ export async function handle_GET_groups(
}) + `|||`
);
}
- // @ts-expect-error flush - calling due to use of compression
- res.flush();
+ // Express response has no flush method, but compression middleware adds it
+ (res as any).flush();
}
export async function handle_GET_topics(
@@ -723,8 +723,8 @@ export async function handle_GET_reportNarrative(
res.write(`POLIS-PING: AI bootstrap`);
- // @ts-expect-error flush - calling due to use of compression
- res.flush();
+ // Express response has no flush method, but compression middleware adds it
+ (res as any).flush();
const zid = await getZidForRid(rid);
if (!zid) {
@@ -734,8 +734,8 @@ export async function handle_GET_reportNarrative(
res.write(`POLIS-PING: retrieving system lore`);
- // @ts-expect-error flush - calling due to use of compression
- res.flush();
+ // Express response has no flush method, but compression middleware adds it
+ (res as any).flush();
const system_lore = await fs.readFile(
"src/report_experimental/system.xml",
@@ -744,8 +744,8 @@ export async function handle_GET_reportNarrative(
res.write(`POLIS-PING: retrieving stream`);
- // @ts-expect-error flush - calling due to use of compression
- res.flush();
+ // Express response has no flush method, but compression middleware adds it
+ (res as any).flush();
try {
const cachedResponse = await storage?.getAllByReportID(rid);
if (
diff --git a/server/src/server.ts b/server/src/server.ts
index dfa2532875..a41ffab057 100644
--- a/server/src/server.ts
+++ b/server/src/server.ts
@@ -4610,7 +4610,7 @@ Email verified! You can close this tab or hit the back button.
return;
}
- if (finalPid && finalPid < 0) {
+ if (finalPid && typeof finalPid === 'number' && finalPid < 0) {
fail(res, 500, "polis_err_post_comment_bad_pid");
return;
}
diff --git a/server/src/utils/pca.ts b/server/src/utils/pca.ts
index f0adf354dc..68386e0ccb 100644
--- a/server/src/utils/pca.ts
+++ b/server/src/utils/pca.ts
@@ -51,7 +51,7 @@ export type PcaCacheItem = {
agree: any[];
disagree: any[];
};
- "meta-tids"?: any[];
+ "meta-tids"?: number[];
"votes-base"?: Record;
"lastModTimestamp"?: number | null;
"lastVoteTimestamp"?: number;
diff --git a/server/test/api.test.ts b/server/test/api.test.ts
deleted file mode 100644
index cb37f775b6..0000000000
--- a/server/test/api.test.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import request from "supertest";
-import app from "../app";
-
-describe("API", () => {
- describe("GET /api/v3/testConnection", () => {
- it("should return 200 OK", () => {
- return request(app).get("/api/v3/testConnection").expect(200);
- });
- });
-
- describe("GET /api/v3/testDatabase", () => {
- it("should return 200 OK", () => {
- return request(app).get("/api/v3/testDatabase").expect(200);
- });
- });
-});
diff --git a/server/test/config.test.ts b/server/test/config.test.ts
deleted file mode 100644
index e48a2ca97e..0000000000
--- a/server/test/config.test.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { jest } from '@jest/globals';
-
-describe("Config", () => {
- beforeEach(() => {
- // reset module state so we can re-import with new env vars
- jest.resetModules();
- });
-
- afterEach(() => {
- // restore replaced properties
- jest.restoreAllMocks();
- });
-
- describe("getServerNameWithProtocol", () => {
- test('returns https://pol.is by default', async () => {
- jest.replaceProperty(process, 'env', {DEV_MODE: 'false'});
-
- const { default: Config } = await import('../src/config');
- const req = {
- protocol: 'http',
- headers: {
- host: 'localhost'
- }
- };
-
- expect(Config.getServerNameWithProtocol(req)).toBe('https://pol.is');
- });
-
- test('returns domain override value when DOMAIN_OVERRIDE is set', async () => {
- jest.replaceProperty(process, 'env', {DEV_MODE: 'false', DOMAIN_OVERRIDE: 'example.co'});
-
- const { default: Config } = await import('../src/config');
- const req = {
- protocol: 'http',
- headers: {
- host: 'localhost'
- }
- };
-
- expect(Config.getServerNameWithProtocol(req)).toBe('http://example.co');
- });
-
- test('returns given req domain when DEV_MODE is true', async () => {
- jest.replaceProperty(process, 'env', {DEV_MODE: 'true', DOMAIN_OVERRIDE: 'example.co'});
-
- const { default: Config } = await import('../src/config');
- const req = {
- protocol: 'https',
- headers: {
- host: 'mydomain.xyz'
- }
- };
-
- expect(Config.getServerNameWithProtocol(req)).toBe('https://mydomain.xyz');
- });
-
- test('returns https://embed.pol.is when req domain contains embed.pol.is', async () => {
- jest.replaceProperty(process, 'env', {DEV_MODE: 'true', DOMAIN_OVERRIDE: 'example.co'});
-
- const { default: Config } = await import('../src/config');
- const req = {
- protocol: 'https',
- headers: {
- host: 'embed.pol.is'
- }
- };
-
- expect(Config.getServerNameWithProtocol(req)).toBe('https://embed.pol.is');
- });
- });
-
- describe("getServerUrl", () => {
- test('returns API_PROD_HOSTNAME when DEV_MODE is false', async () => {
- jest.replaceProperty(process, 'env', {DEV_MODE: 'false', API_PROD_HOSTNAME: 'example.com'});
-
- const { default: Config } = await import('../src/config');
-
- expect(Config.getServerUrl()).toBe('https://example.com');
- });
-
- test('returns https://pol.is when DEV_MODE is false and API_PROD_HOSTNAME is not set', async () => {
- jest.replaceProperty(process, 'env', {DEV_MODE: 'false'});
-
- const { default: Config } = await import('../src/config');
-
- expect(Config.getServerUrl()).toBe('https://pol.is');
- });
-
- test('returns API_DEV_HOSTNAME when DEV_MODE is true', async () => {
- jest.replaceProperty(process, 'env', {DEV_MODE: 'true', API_DEV_HOSTNAME: 'dev.example.com'});
-
- const { default: Config } = await import('../src/config');
-
- expect(Config.getServerUrl()).toBe('http://dev.example.com');
- });
-
- test('returns http://localhost:5000 when DEV_MODE is true and DEV_URL is not set', async () => {
- jest.replaceProperty(process, 'env', {DEV_MODE: 'true'});
-
- const { default: Config } = await import('../src/config');
-
- expect(Config.getServerUrl()).toBe('http://localhost:5000');
- });
- });
-
- describe("whitelistItems", () => {
- test('returns an array of whitelisted items', async () => {
- jest.replaceProperty(process, 'env', {
- DOMAIN_WHITELIST_ITEM_01: 'item1',
- DOMAIN_WHITELIST_ITEM_02: '',
- DOMAIN_WHITELIST_ITEM_03: 'item3',
- });
-
- const { default: Config } = await import('../src/config');
-
- expect(Config.whitelistItems).toEqual(['item1', 'item3']);
- });
- });
-});
diff --git a/server/test/settings/env-setup.ts b/server/test/settings/env-setup.ts
deleted file mode 100644
index ad91759014..0000000000
--- a/server/test/settings/env-setup.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import dotenv from 'dotenv';
-import path from 'path';
-
-dotenv.config({ path: path.resolve(process.cwd(), 'test', 'settings', 'test.env') });
diff --git a/server/test/settings/test.env b/server/test/settings/test.env
deleted file mode 100644
index b90a3ff422..0000000000
--- a/server/test/settings/test.env
+++ /dev/null
@@ -1,3 +0,0 @@
-# Unique values for test environment
-DEV_MODE=true
-API_SERVER_PORT=5050 # Must be different than server port to avoid collision.
diff --git a/server/tsconfig.json b/server/tsconfig.json
index be2c13b32d..de33930812 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -25,8 +25,9 @@
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
- "strict": true /* Enable all strict type-checking options. */,
- // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+ "strict": false /* Disable strict type-checking for test files. */,
+ "noImplicitAny": false, /* Disable error on expressions and declarations with an implied 'any' type for tests. */
+ "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
@@ -76,8 +77,9 @@
/* Advanced Options */
"skipLibCheck": true /* Skip type checking of declaration files. */,
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
- "typeRoots": ["./node_modules/@types"]
+ "typeRoots": ["./node_modules/@types", "./types"],
+ "esModuleInterop": true
},
- "include": ["app.ts", "src/**/*", "types/**/*"],
- "exclude": ["node_modules", "**/*.spec.ts", "dist", "src/report_experimental/topics-example/lib/**/**"]
+ "include": ["index.ts", "app.ts", "src/**/*", "types/**/*"],
+ "exclude": ["node_modules", "dist", "src/report_experimental/topics-example/lib/**/**", "__tests__/**/*"]
}
diff --git a/server/types/express.d.ts b/server/types/express.d.ts
new file mode 100644
index 0000000000..dfa93616ef
--- /dev/null
+++ b/server/types/express.d.ts
@@ -0,0 +1,16 @@
+// Type definitions to extend Express types for our specific needs
+
+import { Express as ExpressType } from 'express';
+
+// Add global declarations
+declare global {
+ namespace Express {
+ interface Request {
+ p: any;
+ timedout?: boolean;
+ }
+ }
+}
+
+// This is necessary to make the TypeScript compiler recognize this as a module
+export {};
\ No newline at end of file
diff --git a/server/types/jest-globals.d.ts b/server/types/jest-globals.d.ts
new file mode 100644
index 0000000000..c2e98c272d
--- /dev/null
+++ b/server/types/jest-globals.d.ts
@@ -0,0 +1,14 @@
+import type { Server } from 'http';
+import type { Agent } from 'supertest';
+
+declare global {
+ // Server and config related globals
+ var __SERVER__: Server | null;
+ var __SERVER_PORT__: number | null;
+ var __API_URL__: string | null;
+ var __API_PREFIX__: string | null;
+
+ // Test agents
+ var __TEST_AGENT__: Agent | null;
+ var __TEXT_AGENT__: Agent | null;
+}
\ No newline at end of file
diff --git a/server/types/test-helpers.d.ts b/server/types/test-helpers.d.ts
new file mode 100644
index 0000000000..14c61a13db
--- /dev/null
+++ b/server/types/test-helpers.d.ts
@@ -0,0 +1,98 @@
+import { Response } from 'supertest';
+import { Express } from 'express';
+import {
+ UserType,
+ ConversationType,
+ CommentType,
+ Vote
+} from '../src/d';
+
+// Augment supertest's Response type
+declare module 'supertest' {
+ interface Response {
+ text: string;
+ }
+}
+
+// Test user data for registration and authentication
+export interface TestUser {
+ email: string;
+ password: string;
+ hname: string;
+}
+
+// Data returned after user registration and login
+export interface AuthData {
+ cookies: string[] | string | undefined;
+ userId: number;
+ agent: any; // SuperTest agent
+ textAgent: any; // SuperTest text agent
+ testUser?: TestUser;
+}
+
+// Data returned after setting up a test conversation
+export interface ConvoData {
+ userId: number;
+ testUser: TestUser;
+ conversationId: string;
+ commentIds: number[];
+}
+
+// Data returned after initializing a participant
+export interface ParticipantData {
+ cookies: string[] | string | undefined;
+ body: any;
+ status: number;
+ agent: any; // SuperTest agent
+ xid?: string;
+}
+
+// Vote data structure
+export interface VoteData {
+ tid: number;
+ conversation_id: string;
+ vote: -1 | 0 | 1;
+ pid?: string;
+ xid?: string;
+ high_priority?: boolean;
+ lang?: string;
+}
+
+// Vote response data
+export interface VoteResponse extends Partial {
+ cookies: string[] | string | undefined;
+ body: {
+ currentPid?: string;
+ [key: string]: any;
+ };
+ text: string;
+ status: number;
+ agent: any; // SuperTest agent
+}
+
+// Conversation options
+export interface ConversationOptions {
+ topic?: string;
+ description?: string;
+ is_active?: boolean;
+ is_anon?: boolean;
+ is_draft?: boolean;
+ strict_moderation?: boolean;
+ profanity_filter?: boolean;
+ [key: string]: any;
+}
+
+// Comment options
+export interface CommentOptions {
+ conversation_id?: string;
+ txt: string;
+ pid?: string;
+ [key: string]: any;
+}
+
+// Response validation options
+export interface ValidationOptions {
+ expectedStatus?: number;
+ errorPrefix?: string;
+ requiredProperties?: string[];
+}
\ No newline at end of file
diff --git a/test.env b/test.env
index c921cc70dd..be12981ea8 100644
--- a/test.env
+++ b/test.env
@@ -5,10 +5,12 @@ DEV_MODE=true
EMAIL_TRANSPORT_TYPES=maildev
NODE_ENV=production
SERVER_ENV_FILE=test.env
-SERVER_LOG_LEVEL=debug
+SERVER_LOG_LEVEL=warn
DOMAIN_OVERRIDE=localhost
EMBED_SERVICE_HOSTNAME=localhost
+MAILDEV_HOST=maildev
+SERVICE_URL=http://localhost
STATIC_FILES_HOST=file-server
POSTGRES_DOCKER=true
@@ -17,9 +19,10 @@ POSTGRES_HOST=postgres:5432
POSTGRES_PASSWORD=PdwPNS2mDN73Vfbc
POSTGRES_PORT=5432
POSTGRES_USER=postgres
-DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
+DATABASE_URL=postgres://postgres:PdwPNS2mDN73Vfbc@postgres:5432/polis-test
MATH_ENV=prod
+MATH_LOG_LEVEL=warn
WEBSERVER_PASS=ws-pass
WEBSERVER_USERNAME=ws-user