diff --git a/docs/prisma-migration-strategy.md b/docs/prisma-migration-strategy.md new file mode 100644 index 00000000..27e654da --- /dev/null +++ b/docs/prisma-migration-strategy.md @@ -0,0 +1,162 @@ +# Prisma Migration & Database Sync Strategy + +## TL;DR + +**Do not run `prisma migrate dev` on this project.** It does not support MongoDB. Use `pnpm prisma:push` (or `pnpm prisma:sync`) instead. + +--- + +## Why Not `prisma migrate dev`? + +Prisma's `migrate dev`, `migrate deploy`, and `migrate reset` commands are **SQL-only**. They generate `.sql` files in `prisma/migrations/` that describe schema diffs — which is meaningless for MongoDB since MongoDB is schemaless at the database level. + +Running `prisma migrate dev` against a MongoDB datasource fails with: + +``` +Error: Prisma migrate does not support MongoDB. +``` + +The ticket text (7.27) references `prisma migrate dev --name init`, but the correct MongoDB equivalent is `prisma db push`. + +## The MongoDB Sync Workflow + +### Everyday flow (schema change → live DB) + +1. Edit schema files in `prisma/schema/*.prisma` +2. Validate syntax: + ```bash + pnpm prisma:validate + ``` +3. Push schema to the live MongoDB (creates/updates indexes, syncs metadata): + ```bash + pnpm prisma:push + ``` +4. Regenerate Prisma Client so TypeScript sees the new types: + ```bash + pnpm prisma:generate + ``` + +Or run the combined command: + +```bash +pnpm prisma:sync # = prisma:push && prisma:generate +``` + +### What `db push` actually does + +- Syncs the Prisma schema metadata with the connected MongoDB database +- Creates any missing indexes defined in `@@index` / `@@unique` +- **Does NOT** create migration history files — MongoDB is authoritative for data, Prisma only tracks schema shape +- Is **idempotent** — safe to run repeatedly +- Does **NOT** drop data unless `--accept-data-loss` is explicitly passed + +## Relationship to Mongoose + +Both Mongoose (primary ORM) and Prisma (migration target) read and write the same MongoDB collections. Field-name mismatches between the two ORMs are bridged with `@map()` directives on the Prisma schema (see `docs/prisma-relation-design.md`). + +Because MongoDB is schemaless, **old documents written before a new field was added will simply not have that field** — they'll surface as `null` / `undefined` on read. This is acceptable for optional fields and `@default(now())` fields (which only apply on new inserts). For required fields added later, a backfill script is needed (see below). + +## Data Transformation / Backfill Pattern + +If a future schema change requires transforming existing documents (e.g., renaming a field across all docs, splitting a field, enforcing a new required field), put a one-off script in `scripts/migrations/` following this pattern: + +```ts +// scripts/migrations/YYYY-MM-DD-description.ts +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + // 1. Query affected docs + // 2. Transform each doc + // 3. updateMany / $currentDate / etc + // 4. Log count of transformed docs +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); +``` + +Rules: + +- **Idempotent** — safe to re-run. Use `$set` + a marker field, or check for the new shape before transforming. +- **Logged** — print before/after counts so an operator can verify. +- **Guarded** — use `if (process.env.NODE_ENV !== 'production') { ... }` guards where destructive. +- **Committed** — keep migration scripts in version control forever, even after they've run, as a record of past changes. + +No backfill scripts exist yet because all new fields landed in 7.25–7.26 are either optional, `@default(now())`, or arrays with sensible defaults. + +## Parity Verification: `pnpm prisma:parity` + +Because Mongoose and Prisma coexist, the risk is that field-name mappings drift silently — for example, renaming a Mongoose field without updating the corresponding Prisma `@map()`. + +`scripts/prisma-mongoose-parity.ts` (wired as `pnpm prisma:parity`) detects drift at runtime: + +1. Connects both Mongoose and Prisma to the same DB +2. For each critical model (User, Post, MessageRoom), runs two round-trips: + - **Mongoose → Prisma**: create doc via Mongoose, read via Prisma, assert all mapped fields (`admin`/`isAdmin`, `_followingId`/`followingIds`, `enable_voting`/`enableVoting`, `users`/`userIds`) surface correctly on the Prisma side + - **Prisma → Mongoose**: create via Prisma, read raw via Mongoose, assert the MongoDB document uses the expected field names +3. Runs a **functional parity** round — seeds a `User`+`Group`+`Post` via Mongoose, then: + - Reads the post via `Post.findById(...).populate('userId', 'username')` (Mongoose) + - Reads the same post via `prisma.post.findUnique({ include: { user: { select: ... } } })` (Prisma) + - Asserts the post title, populated author id, and author username match between the two ORMs + - Asserts `Post.countDocuments({ userId })` equals `prisma.post.count({ where: { userId } })` +4. Cleans up all docs it created (LIFO, in a `finally` block) +5. Exits `0` on success, `1` on any mismatch (with a diff report) + +Run it whenever schema/mapping changes: + +```bash +pnpm prisma:parity +``` + +**Requires a live MongoDB instance running as a replica set** (local or staging). Not run in CI for now. + +### MongoDB replica set requirement + +Prisma's MongoDB connector uses transactions internally for `create`/`update`/`delete`, and MongoDB only supports transactions on replica sets. A standalone `mongod` will fail with: + +``` +Prisma needs to perform transactions, which requires your MongoDB server to be run as a replica set. +``` + +**Fix — pick one:** + +1. **Local single-node replica set:** start `mongod --replSet rs0`, then once in `mongosh`: `rs.initiate()`. Update `DATABASE_URL` to include `?replicaSet=rs0&directConnection=true`. +2. **Docker:** `docker run -d --name mongo-rs -p 27017:27017 mongo:7 --replSet rs0 --bind_ip_all` then `docker exec -it mongo-rs mongosh --eval "rs.initiate()"`. +3. **MongoDB Atlas:** Atlas clusters are already replica sets — just paste the connection string into `DATABASE_URL`. + +Mongoose operations work against a standalone because Mongoose issues plain `insertOne`/`updateOne` without wrapping them in a transaction — so this constraint only surfaces once Prisma is introduced. + +## Integrity Checks + +Beyond parity, these commands verify the DB state: + +| Command | What it checks | +|---|---| +| `pnpm prisma:validate` | Schema syntax is valid (static check, no DB needed) | +| `pnpm prisma:health` | DB connection works, all 24 models are visible | +| `pnpm prisma:test` | CRUD + relationship round-trip for **all 24 models** (User, Group, Post, Comment, Quote, Vote, VoteLog, Reaction, Message, DirectMessage, MessageRoom, Notification, Activity, Roster, Presence, Typing, UserInvite, UserReport, BotReport, UserReputation, Domain, Creator, Content, Collection) plus 3-level deep traversals | +| `pnpm test:prisma` | Full Jest integration suite — every model, unique-constraint enforcement, 3-level deep `include` traversal (user → posts → comments → commenter; room → messages → reactions → reactor), and a delegate smoke test that calls `count()` on all 24 models | +| `pnpm prisma:parity` | Mongoose↔Prisma field-mapping parity **plus functional parity** — the same logical query via Mongoose `populate()` and Prisma `include()` must return equivalent author id/username and document counts | + +## When to Re-Sync + +Re-run `pnpm prisma:sync` after: + +- Adding or removing a model +- Adding/removing/renaming a field on any model +- Changing an index (`@@index`, `@@unique`) +- Changing an enum +- Bumping `@prisma/client` or `prisma` major versions + +Skip the push if only: + +- Editing comments +- Reformatting (`pnpm prisma:format`) +- Adding relation fields that don't change the underlying indexes diff --git a/docs/prisma-relation-design.md b/docs/prisma-relation-design.md new file mode 100644 index 00000000..b18838a0 --- /dev/null +++ b/docs/prisma-relation-design.md @@ -0,0 +1,50 @@ +# Prisma Relation Design — MongoDB + +## Overview + +QuoteVote uses MongoDB with both Mongoose (primary) and Prisma (migration target) reading from the same collections. This document covers key design decisions for Prisma schema alignment. + +## Logical References, Not Foreign Keys + +MongoDB does not enforce foreign key constraints at the database level. All relations in Prisma are **logical references** managed at the application layer. This means: + +- Prisma `@relation` attributes define how the client resolves `include` queries +- No cascading deletes happen automatically — cleanup is application responsibility +- Orphaned references are possible if documents are deleted without updating referrers + +## Field Name Mapping (`@map`) + +Since Mongoose and Prisma share the same MongoDB collections, Prisma field names must match the actual MongoDB document field names. Where Prisma uses camelCase but MongoDB stores snake_case or prefixed names, `@map()` bridges the gap: + +| Prisma Field | MongoDB Field | Mapping | +|---|---|---| +| `User.isAdmin` | `admin` | `@map("admin")` | +| `User.followingIds` | `_followingId` | `@map("_followingId")` | +| `User.followerIds` | `_followersId` | `@map("_followersId")` | +| `Post.enableVoting` | `enable_voting` | `@map("enable_voting")` | +| `MessageRoom.userIds` | `users` | `@map("users")` | +| `Message.mutationType` | `mutation_type` | `@map("mutation_type")` | + +## Model Naming + +The Prisma model `Message` maps to the `messages` collection via `@@map("messages")`, matching the Mongoose model name `Message`. The legacy `DirectMessage` model maps to `directmessages` for the old `Messages.js` simple messages. + +## Embedded vs Referenced Documents + +- **User.reputation**: Mongoose embeds reputation data directly on the User document. Prisma uses an `EmbeddedReputation` composite type to match this. +- **UserReputation**: A separate standalone model also exists (from `UserReputation.ts`), used for detailed reputation tracking with history. +- **Message.readByDetailed / deliveredTo**: Mongoose uses embedded subdocuments. Prisma uses composite types `ReadByDetail` and `DeliveredToDetail`. + +## Array Type Decisions + +Some Mongoose arrays store plain strings (not ObjectIds): + +- `Post.bookmarkedBy`, `Post.rejectedBy`, `Post.approvedBy`, `Post.reportedBy`, `Post.votedBy` — all `String[]` in Prisma (no `@db.ObjectId`) + +Arrays that store ObjectId references use `@db.ObjectId`: + +- `User.followingIds`, `User.followerIds`, `User.blockedUserIds`, `MessageRoom.userIds`, `Message.readBy` + +## Many-to-Many via Arrays + +MongoDB handles many-to-many relationships using ObjectId arrays rather than junction tables. For example, `MessageRoom.userIds` stores an array of User ObjectIds. Prisma models this as `String[] @db.ObjectId` since MongoDB Prisma does not support implicit many-to-many relations. diff --git a/quotevote-backend/__tests__/integration/prisma-dependent-models.test.ts b/quotevote-backend/__tests__/integration/prisma-dependent-models.test.ts new file mode 100644 index 00000000..1167b94f --- /dev/null +++ b/quotevote-backend/__tests__/integration/prisma-dependent-models.test.ts @@ -0,0 +1,1229 @@ +/** + * Prisma Dependent Models — Relationship CRUD Tests + * + * Tests Create, Read, Update, Delete operations and relationship traversal + * for all dependent domain models defined in prisma/schema/*.prisma. + * + * Requires a running MongoDB instance with DATABASE_URL in .env + * + * Run: npx jest __tests__/integration/prisma-dependent-models.test.ts + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// Typed delete functions for cleanup — no `any` needed +const deleteFns: Record Promise> = { + user: (id) => prisma.user.delete({ where: { id } }), + group: (id) => prisma.group.delete({ where: { id } }), + post: (id) => prisma.post.delete({ where: { id } }), + comment: (id) => prisma.comment.delete({ where: { id } }), + quote: (id) => prisma.quote.delete({ where: { id } }), + vote: (id) => prisma.vote.delete({ where: { id } }), + voteLog: (id) => prisma.voteLog.delete({ where: { id } }), + reaction: (id) => prisma.reaction.delete({ where: { id } }), + message: (id) => prisma.message.delete({ where: { id } }), + directMessage: (id) => prisma.directMessage.delete({ where: { id } }), + messageRoom: (id) => prisma.messageRoom.delete({ where: { id } }), + notification: (id) => prisma.notification.delete({ where: { id } }), + presence: (id) => prisma.presence.delete({ where: { id } }), + roster: (id) => prisma.roster.delete({ where: { id } }), + typing: (id) => prisma.typing.delete({ where: { id } }), + userInvite: (id) => prisma.userInvite.delete({ where: { id } }), + userReport: (id) => prisma.userReport.delete({ where: { id } }), + botReport: (id) => prisma.botReport.delete({ where: { id } }), + userReputation: (id) => prisma.userReputation.delete({ where: { id } }), + activity: (id) => prisma.activity.delete({ where: { id } }), + domain: (id) => prisma.domain.delete({ where: { id } }), + creator: (id) => prisma.creator.delete({ where: { id } }), + content: (id) => prisma.content.delete({ where: { id } }), + collection: (id) => prisma.collection.delete({ where: { id } }), +}; + +// Track created IDs for cleanup +const cleanup: { model: string; id: string }[] = []; + +// Helper to register entities for cleanup (LIFO order) +function track(model: string, id: string) { + cleanup.unshift({ model, id }); +} + +// Helper to create a unique test user +async function createTestUser(suffix: string) { + const user = await prisma.user.create({ + data: { + email: `test-${suffix}-${Date.now()}@prisma-test.com`, + username: `testuser_${suffix}_${Date.now()}`, + name: `Test ${suffix}`, + accountStatus: 'active', + }, + }); + track('user', user.id); + return user; +} + +// Helper to create a test group +async function createTestGroup(creatorId: string) { + const group = await prisma.group.create({ + data: { + creatorId, + title: `Test Group ${Date.now()}`, + privacy: 'public', + }, + }); + track('group', group.id); + return group; +} + +// Helper to create a test post +async function createTestPost(userId: string, groupId: string) { + const post = await prisma.post.create({ + data: { + userId, + groupId, + title: `Test Post ${Date.now()}`, + text: 'Test post content for relationship testing.', + }, + }); + track('post', post.id); + return post; +} + +// Replica-set guard — Prisma requires MongoDB replica sets for create/update/delete +// (transactions). Detect a non-replica-set mongod up front and fail fast with a +// readable error instead of a cryptic mid-suite P2031. +beforeAll(async () => { + await prisma.$connect(); + + const stamp = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + try { + const probe = await prisma.user.create({ + data: { + email: `replica-probe-${stamp}@internal.test`, + username: `replica_probe_${stamp}`, + accountStatus: 'active', + }, + }); + track('user', probe.id); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/replica set/i.test(msg) || /P2031/.test(msg)) { + throw new Error( + '\n\n⚠️ MongoDB is not running as a replica set.\n' + + ' Prisma requires a replica set for create/update/delete (transactions).\n' + + ' Fix (fastest): docker run -d --name mongo-rs -p 27017:27017 \\\n' + + ' mongo:7 --replSet rs0 --bind_ip_all\n' + + ' docker exec -it mongo-rs mongosh --eval "rs.initiate()"\n' + + ' Then update DATABASE_URL to include ?replicaSet=rs0&directConnection=true\n' + + ' See docs/prisma-migration-strategy.md for all options.\n', + ); + } + throw err; + } +}); + +afterAll(async () => { + // Cleanup in reverse creation order + for (const { model, id } of cleanup) { + try { + const deleteFn = deleteFns[model]; + if (deleteFn) await deleteFn(id); + } catch { + // Already deleted or doesn't exist — ignore + } + } + await prisma.$disconnect(); +}); + +// ============================================================================ +// 1. User Model +// ============================================================================ +describe('User Model', () => { + it('should create a user with defaults', async () => { + const user = await createTestUser('user'); + expect(user.id).toBeDefined(); + expect(user.accountStatus).toBe('active'); + expect(user.tokens).toBe(0); + expect(user.upvotes).toBe(0); + expect(user.downvotes).toBe(0); + expect(user.contributorBadge).toBe(false); + expect(user.isAdmin).toBe(false); + expect(user.joined).toBeInstanceOf(Date); + }); + + it('should update a user', async () => { + const user = await createTestUser('userupd'); + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { name: 'Updated Name', upvotes: 5 }, + }); + expect(updated.name).toBe('Updated Name'); + expect(updated.upvotes).toBe(5); + }); + + it('should find user by username', async () => { + const user = await createTestUser('userfind'); + const found = await prisma.user.findUnique({ where: { username: user.username } }); + expect(found).not.toBeNull(); + expect(found!.id).toBe(user.id); + }); +}); + +// ============================================================================ +// 2. Post → User, Group relationships +// ============================================================================ +describe('Post Model & Relations', () => { + it('should create a post linked to user and group', async () => { + const user = await createTestUser('postuser'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + expect(post.userId).toBe(user.id); + expect(post.groupId).toBe(group.id); + expect(post.deleted).toBe(false); + expect(post.upvotes).toBe(0); + }); + + it('should query user → posts relation', async () => { + const user = await createTestUser('postrel'); + const group = await createTestGroup(user.id); + await createTestPost(user.id, group.id); + await createTestPost(user.id, group.id); + + const userWithPosts = await prisma.user.findUnique({ + where: { id: user.id }, + include: { posts: true }, + }); + expect(userWithPosts!.posts.length).toBe(2); + }); + + it('should query post → user relation', async () => { + const user = await createTestUser('postback'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const postWithUser = await prisma.post.findUnique({ + where: { id: post.id }, + include: { user: true }, + }); + expect(postWithUser!.user.id).toBe(user.id); + }); +}); + +// ============================================================================ +// 3. Comment → Post, User +// ============================================================================ +describe('Comment Model & Relations', () => { + it('should create a comment on a post', async () => { + const user = await createTestUser('commentuser'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const comment = await prisma.comment.create({ + data: { + userId: user.id, + postId: post.id, + content: 'Test comment', + startWordIndex: 0, + endWordIndex: 3, + }, + }); + track('comment', comment.id); + + expect(comment.content).toBe('Test comment'); + expect(comment.deleted).toBe(false); + }); + + it('should query post → comments relation', async () => { + const user = await createTestUser('commentrel'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const c1 = await prisma.comment.create({ + data: { userId: user.id, postId: post.id, content: 'Comment 1' }, + }); + const c2 = await prisma.comment.create({ + data: { userId: user.id, postId: post.id, content: 'Comment 2' }, + }); + track('comment', c1.id); + track('comment', c2.id); + + const postWithComments = await prisma.post.findUnique({ + where: { id: post.id }, + include: { comments: true }, + }); + expect(postWithComments!.comments.length).toBe(2); + }); +}); + +// ============================================================================ +// 4. Quote → Post, User +// ============================================================================ +describe('Quote Model & Relations', () => { + it('should create a quote linked to post and user', async () => { + const user = await createTestUser('quoteuser'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const quote = await prisma.quote.create({ + data: { + userId: user.id, + postId: post.id, + quote: 'This is a test quote', + startWordIndex: 0, + endWordIndex: 5, + }, + }); + track('quote', quote.id); + + expect(quote.quote).toBe('This is a test quote'); + }); + + it('should query post → quotes relation', async () => { + const user = await createTestUser('quoterel'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const q = await prisma.quote.create({ + data: { userId: user.id, postId: post.id, quote: 'Quote 1' }, + }); + track('quote', q.id); + + const postWithQuotes = await prisma.post.findUnique({ + where: { id: post.id }, + include: { quotes: true }, + }); + expect(postWithQuotes!.quotes.length).toBe(1); + expect(postWithQuotes!.quotes[0].quote).toBe('Quote 1'); + }); +}); + +// ============================================================================ +// 5. Vote → Post, User +// ============================================================================ +describe('Vote Model & Relations', () => { + it('should create an upvote and a downvote', async () => { + const user = await createTestUser('voteuser'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const upvote = await prisma.vote.create({ + data: { userId: user.id, postId: post.id, type: 'up' }, + }); + const downvote = await prisma.vote.create({ + data: { userId: user.id, postId: post.id, type: 'down' }, + }); + track('vote', upvote.id); + track('vote', downvote.id); + + expect(upvote.type).toBe('up'); + expect(downvote.type).toBe('down'); + expect(upvote.deleted).toBe(false); + }); + + it('should query post → votes relation', async () => { + const user = await createTestUser('voterel'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const v = await prisma.vote.create({ + data: { userId: user.id, postId: post.id, type: 'up', tags: ['insightful'] }, + }); + track('vote', v.id); + + const postWithVotes = await prisma.post.findUnique({ + where: { id: post.id }, + include: { votes: true }, + }); + expect(postWithVotes!.votes.length).toBe(1); + expect(postWithVotes!.votes[0].tags).toContain('insightful'); + }); +}); + +// ============================================================================ +// 6. MessageRoom + Message → User relationships +// ============================================================================ +describe('MessageRoom & Message Relations', () => { + it('should create a message room and messages', async () => { + const user = await createTestUser('msguser'); + + const room = await prisma.messageRoom.create({ + data: { + userIds: [user.id], + messageType: 'USER', + isDirect: true, + }, + }); + track('messageRoom', room.id); + + const msg = await prisma.message.create({ + data: { + messageRoomId: room.id, + userId: user.id, + text: 'Hello world', + }, + }); + track('message', msg.id); + + expect(msg.text).toBe('Hello world'); + expect(msg.deleted).toBe(false); + }); + + it('should query room → messages relation', async () => { + const user = await createTestUser('msgrel'); + + const room = await prisma.messageRoom.create({ + data: { userIds: [user.id], messageType: 'USER' }, + }); + track('messageRoom', room.id); + + const m1 = await prisma.message.create({ + data: { messageRoomId: room.id, userId: user.id, text: 'Msg 1' }, + }); + const m2 = await prisma.message.create({ + data: { messageRoomId: room.id, userId: user.id, text: 'Msg 2' }, + }); + track('message', m1.id); + track('message', m2.id); + + const roomWithMessages = await prisma.messageRoom.findUnique({ + where: { id: room.id }, + include: { messages: true }, + }); + expect(roomWithMessages!.messages.length).toBe(2); + }); +}); + +// ============================================================================ +// 7. Reaction → Message, User +// ============================================================================ +describe('Reaction Model & Relations', () => { + it('should create a reaction on a message', async () => { + const user = await createTestUser('reactuser'); + const room = await prisma.messageRoom.create({ + data: { userIds: [user.id], messageType: 'USER' }, + }); + track('messageRoom', room.id); + + const msg = await prisma.message.create({ + data: { messageRoomId: room.id, userId: user.id, text: 'React to this' }, + }); + track('message', msg.id); + + const reaction = await prisma.reaction.create({ + data: { userId: user.id, messageId: msg.id, emoji: '👍' }, + }); + track('reaction', reaction.id); + + expect(reaction.emoji).toBe('👍'); + }); + + it('should query message → reactions relation', async () => { + const user = await createTestUser('reactrel'); + const room = await prisma.messageRoom.create({ + data: { userIds: [user.id], messageType: 'USER' }, + }); + track('messageRoom', room.id); + + const msg = await prisma.message.create({ + data: { messageRoomId: room.id, userId: user.id, text: 'Reactions test' }, + }); + track('message', msg.id); + + const r = await prisma.reaction.create({ + data: { userId: user.id, messageId: msg.id, emoji: '❤️' }, + }); + track('reaction', r.id); + + const msgWithReactions = await prisma.message.findUnique({ + where: { id: msg.id }, + include: { reactions: true }, + }); + expect(msgWithReactions!.reactions.length).toBe(1); + }); +}); + +// ============================================================================ +// 8. Presence → User +// ============================================================================ +describe('Presence Model & Relations', () => { + it('should create presence for a user', async () => { + const user = await createTestUser('presuser'); + + const presence = await prisma.presence.create({ + data: { userId: user.id, status: 'online' }, + }); + track('presence', presence.id); + + expect(presence.status).toBe('online'); + expect(presence.userId).toBe(user.id); + }); + + it('should query user → presence relation', async () => { + const user = await createTestUser('presrel'); + + const p = await prisma.presence.create({ + data: { userId: user.id, status: 'away', statusMessage: 'BRB' }, + }); + track('presence', p.id); + + const userWithPresence = await prisma.user.findUnique({ + where: { id: user.id }, + include: { presence: true }, + }); + expect(userWithPresence!.presence).not.toBeNull(); + expect(userWithPresence!.presence!.status).toBe('away'); + expect(userWithPresence!.presence!.statusMessage).toBe('BRB'); + }); +}); + +// ============================================================================ +// 9. Roster → User (bidirectional buddy) +// ============================================================================ +describe('Roster Model & Relations', () => { + it('should create a roster entry between two users', async () => { + const user1 = await createTestUser('roster1'); + const user2 = await createTestUser('roster2'); + + const roster = await prisma.roster.create({ + data: { + userId: user1.id, + buddyId: user2.id, + status: 'pending', + initiatedBy: user1.id, + }, + }); + track('roster', roster.id); + + expect(roster.status).toBe('pending'); + }); + + it('should enforce unique userId+buddyId constraint', async () => { + const user1 = await createTestUser('rosteruniq1'); + const user2 = await createTestUser('rosteruniq2'); + + const r = await prisma.roster.create({ + data: { userId: user1.id, buddyId: user2.id, status: 'accepted' }, + }); + track('roster', r.id); + + await expect( + prisma.roster.create({ + data: { userId: user1.id, buddyId: user2.id, status: 'pending' }, + }) + ).rejects.toThrow(); + }); + + it('should query user → rosters relation', async () => { + const user1 = await createTestUser('rosterrel1'); + const user2 = await createTestUser('rosterrel2'); + + const r = await prisma.roster.create({ + data: { userId: user1.id, buddyId: user2.id, status: 'accepted' }, + }); + track('roster', r.id); + + const userWithRosters = await prisma.user.findUnique({ + where: { id: user1.id }, + include: { rosters: true, buddyRosters: true }, + }); + expect(userWithRosters!.rosters.length).toBe(1); + }); +}); + +// ============================================================================ +// 10. Typing → MessageRoom, User +// ============================================================================ +describe('Typing Model & Relations', () => { + it('should create a typing indicator', async () => { + const user = await createTestUser('typeuser'); + const room = await prisma.messageRoom.create({ + data: { userIds: [user.id], messageType: 'USER' }, + }); + track('messageRoom', room.id); + + const typing = await prisma.typing.create({ + data: { messageRoomId: room.id, userId: user.id, isTyping: true }, + }); + track('typing', typing.id); + + expect(typing.isTyping).toBe(true); + }); + + it('should enforce unique messageRoomId+userId constraint', async () => { + const user = await createTestUser('typeuniq'); + const room = await prisma.messageRoom.create({ + data: { userIds: [user.id], messageType: 'USER' }, + }); + track('messageRoom', room.id); + + const t = await prisma.typing.create({ + data: { messageRoomId: room.id, userId: user.id }, + }); + track('typing', t.id); + + await expect( + prisma.typing.create({ + data: { messageRoomId: room.id, userId: user.id }, + }) + ).rejects.toThrow(); + }); + + it('should query room → typingIndicators relation', async () => { + const user = await createTestUser('typerel'); + const room = await prisma.messageRoom.create({ + data: { userIds: [user.id], messageType: 'USER' }, + }); + track('messageRoom', room.id); + + const t = await prisma.typing.create({ + data: { messageRoomId: room.id, userId: user.id }, + }); + track('typing', t.id); + + const roomWithTyping = await prisma.messageRoom.findUnique({ + where: { id: room.id }, + include: { typingIndicators: true }, + }); + expect(roomWithTyping!.typingIndicators.length).toBe(1); + }); +}); + +// ============================================================================ +// 11. UserReport → User (reporter + reported) +// ============================================================================ +describe('UserReport Model & Relations', () => { + it('should create a user report', async () => { + const reporter = await createTestUser('reporter'); + const reported = await createTestUser('reported'); + + const report = await prisma.userReport.create({ + data: { + reporterId: reporter.id, + reportedUserId: reported.id, + reason: 'spam', + description: 'Test report', + }, + }); + track('userReport', report.id); + + expect(report.reason).toBe('spam'); + expect(report.status).toBe('pending'); + expect(report.severity).toBe('medium'); + }); + + it('should query user → reports relations', async () => { + const reporter = await createTestUser('reprel1'); + const reported = await createTestUser('reprel2'); + + const r = await prisma.userReport.create({ + data: { reporterId: reporter.id, reportedUserId: reported.id, reason: 'harassment' }, + }); + track('userReport', r.id); + + const reporterWithReports = await prisma.user.findUnique({ + where: { id: reporter.id }, + include: { userReportsMade: true }, + }); + expect(reporterWithReports!.userReportsMade.length).toBe(1); + + const reportedWithReports = await prisma.user.findUnique({ + where: { id: reported.id }, + include: { userReportsReceived: true }, + }); + expect(reportedWithReports!.userReportsReceived.length).toBe(1); + }); +}); + +// ============================================================================ +// 12. Deep Nested Relations: User → Post → Comments + Votes + Quotes +// ============================================================================ +describe('Deep Nested Relationship Traversal', () => { + it('should traverse user → posts → comments + votes + quotes', async () => { + const user = await createTestUser('deeprel'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const comment = await prisma.comment.create({ + data: { userId: user.id, postId: post.id, content: 'Deep comment' }, + }); + const vote = await prisma.vote.create({ + data: { userId: user.id, postId: post.id, type: 'up' }, + }); + const quote = await prisma.quote.create({ + data: { userId: user.id, postId: post.id, quote: 'Deep quote' }, + }); + track('comment', comment.id); + track('vote', vote.id); + track('quote', quote.id); + + const userDeep = await prisma.user.findUnique({ + where: { id: user.id }, + include: { + posts: { + include: { + comments: true, + votes: true, + quotes: true, + }, + }, + }, + }); + + expect(userDeep!.posts.length).toBe(1); + expect(userDeep!.posts[0].comments.length).toBe(1); + expect(userDeep!.posts[0].votes.length).toBe(1); + expect(userDeep!.posts[0].quotes.length).toBe(1); + expect(userDeep!.posts[0].comments[0].content).toBe('Deep comment'); + expect(userDeep!.posts[0].votes[0].type).toBe('up'); + expect(userDeep!.posts[0].quotes[0].quote).toBe('Deep quote'); + }); + + it('should traverse room → messages → reactions', async () => { + const user = await createTestUser('deepmsg'); + const room = await prisma.messageRoom.create({ + data: { userIds: [user.id], messageType: 'USER' }, + }); + track('messageRoom', room.id); + + const msg = await prisma.message.create({ + data: { messageRoomId: room.id, userId: user.id, text: 'Deep message' }, + }); + track('message', msg.id); + + const reaction = await prisma.reaction.create({ + data: { userId: user.id, messageId: msg.id, emoji: '🔥' }, + }); + track('reaction', reaction.id); + + const roomDeep = await prisma.messageRoom.findUnique({ + where: { id: room.id }, + include: { + messages: { + include: { reactions: true }, + }, + }, + }); + + expect(roomDeep!.messages.length).toBe(1); + expect(roomDeep!.messages[0].reactions.length).toBe(1); + expect(roomDeep!.messages[0].reactions[0].emoji).toBe('🔥'); + }); +}); + +// ============================================================================ +// 13. Activity → User, Post, Vote, Comment, Quote +// ============================================================================ +describe('Activity Model & Relations', () => { + it('should create an activity linked to user and post', async () => { + const user = await createTestUser('actuser'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const activity = await prisma.activity.create({ + data: { + userId: user.id, + postId: post.id, + activityType: 'POSTED', + content: 'Created a post', + }, + }); + track('activity', activity.id); + + expect(activity.activityType).toBe('POSTED'); + }); + + it('should query user → activities relation', async () => { + const user = await createTestUser('actrel'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const a = await prisma.activity.create({ + data: { userId: user.id, postId: post.id, activityType: 'VOTED' }, + }); + track('activity', a.id); + + const userWithActivities = await prisma.user.findUnique({ + where: { id: user.id }, + include: { activities: true }, + }); + expect(userWithActivities!.activities.length).toBe(1); + }); +}); + +// ============================================================================ +// 14. VoteLog → User, Post, Vote +// ============================================================================ +describe('VoteLog Model & Relations', () => { + it('should create a vote log entry', async () => { + const user = await createTestUser('vloguser'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + const vote = await prisma.vote.create({ + data: { userId: user.id, postId: post.id, type: 'up' }, + }); + track('vote', vote.id); + + const log = await prisma.voteLog.create({ + data: { + userId: user.id, + voteId: vote.id, + postId: post.id, + description: 'User cast an upvote', + type: 'up', + tokens: 1, + }, + }); + track('voteLog', log.id); + + expect(log.voteId).toBe(vote.id); + expect(log.tokens).toBe(1); + }); + + it('should query user → voteLog relation', async () => { + const user = await createTestUser('vlogrel'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + const vote = await prisma.vote.create({ + data: { userId: user.id, postId: post.id, type: 'down' }, + }); + track('vote', vote.id); + const log = await prisma.voteLog.create({ + data: { + userId: user.id, + voteId: vote.id, + postId: post.id, + description: 'downvote', + type: 'down', + tokens: 1, + }, + }); + track('voteLog', log.id); + + const userWithLogs = await prisma.user.findUnique({ + where: { id: user.id }, + include: { voteLog: true }, + }); + expect(userWithLogs!.voteLog.length).toBe(1); + }); +}); + +// ============================================================================ +// 15. DirectMessage → User +// ============================================================================ +describe('DirectMessage Model & Relations', () => { + it('should create a direct message for a user', async () => { + const user = await createTestUser('dmuser'); + const dm = await prisma.directMessage.create({ + data: { userId: user.id, text: 'Direct hello', title: 'DM' }, + }); + track('directMessage', dm.id); + + expect(dm.text).toBe('Direct hello'); + }); + + it('should query user → directMessages relation', async () => { + const user = await createTestUser('dmrel'); + const dm = await prisma.directMessage.create({ + data: { userId: user.id, text: 'DM 1' }, + }); + track('directMessage', dm.id); + + const userWithDms = await prisma.user.findUnique({ + where: { id: user.id }, + include: { directMessages: true }, + }); + expect(userWithDms!.directMessages.length).toBe(1); + }); +}); + +// ============================================================================ +// 16. Notification → User (recipient + sender), Post +// ============================================================================ +describe('Notification Model & Relations', () => { + it('should create a notification', async () => { + const recipient = await createTestUser('notifrec'); + const sender = await createTestUser('notifsend'); + const group = await createTestGroup(sender.id); + const post = await createTestPost(sender.id, group.id); + + const notif = await prisma.notification.create({ + data: { + userId: recipient.id, + userIdBy: sender.id, + label: 'upvoted your post', + notificationType: 'UPVOTED', + postId: post.id, + }, + }); + track('notification', notif.id); + + expect(notif.status).toBe('new'); + }); + + it('should query sender and recipient relations', async () => { + const recipient = await createTestUser('notifrel1'); + const sender = await createTestUser('notifrel2'); + + const n = await prisma.notification.create({ + data: { + userId: recipient.id, + userIdBy: sender.id, + label: 'followed you', + notificationType: 'FOLLOW', + }, + }); + track('notification', n.id); + + const full = await prisma.notification.findUnique({ + where: { id: n.id }, + include: { user: true, sender: true }, + }); + expect(full!.user.id).toBe(recipient.id); + expect(full!.sender.id).toBe(sender.id); + }); +}); + +// ============================================================================ +// 17. UserInvite → User (optional sender) +// ============================================================================ +describe('UserInvite Model & Relations', () => { + it('should create an invite with status defaults', async () => { + const inviter = await createTestUser('inviter'); + const invite = await prisma.userInvite.create({ + data: { + email: `invitee-${Date.now()}@test.com`, + invitedById: inviter.id, + code: `CODE-${Date.now()}`, + }, + }); + track('userInvite', invite.id); + + expect(invite.status).toBe('pending'); + }); + + it('should query inviter → inviteSent relation', async () => { + const inviter = await createTestUser('inviterrel'); + const inv = await prisma.userInvite.create({ + data: { + email: `invitee2-${Date.now()}@test.com`, + invitedById: inviter.id, + }, + }); + track('userInvite', inv.id); + + const withInvites = await prisma.user.findUnique({ + where: { id: inviter.id }, + include: { inviteSent: true }, + }); + expect(withInvites!.inviteSent.length).toBe(1); + }); +}); + +// ============================================================================ +// 18. BotReport → User (reporter + reported) +// ============================================================================ +describe('BotReport Model & Relations', () => { + it('should create a bot report', async () => { + const reporter = await createTestUser('botreprt'); + const reported = await createTestUser('botrepd'); + + const r = await prisma.botReport.create({ + data: { reporterId: reporter.id, userId: reported.id }, + }); + track('botReport', r.id); + + expect(r.reporterId).toBe(reporter.id); + expect(r.userId).toBe(reported.id); + }); + + it('should enforce unique (reporterId, userId) constraint', async () => { + const reporter = await createTestUser('botuniq1'); + const reported = await createTestUser('botuniq2'); + + const r = await prisma.botReport.create({ + data: { reporterId: reporter.id, userId: reported.id }, + }); + track('botReport', r.id); + + await expect( + prisma.botReport.create({ + data: { reporterId: reporter.id, userId: reported.id }, + }) + ).rejects.toThrow(); + }); + + it('should query user → botReportsMade/botReportsReceived', async () => { + const reporter = await createTestUser('botrel1'); + const reported = await createTestUser('botrel2'); + const r = await prisma.botReport.create({ + data: { reporterId: reporter.id, userId: reported.id }, + }); + track('botReport', r.id); + + const made = await prisma.user.findUnique({ + where: { id: reporter.id }, + include: { botReportsMade: true }, + }); + const received = await prisma.user.findUnique({ + where: { id: reported.id }, + include: { botReportsReceived: true }, + }); + expect(made!.botReportsMade.length).toBe(1); + expect(received!.botReportsReceived.length).toBe(1); + }); +}); + +// ============================================================================ +// 19. UserReputation → User (1-to-1) +// ============================================================================ +describe('UserReputation Model & Relations', () => { + it('should create a reputation record with embedded metrics', async () => { + const user = await createTestUser('repuser'); + const rep = await prisma.userReputation.create({ + data: { + userId: user.id, + overallScore: 75, + inviteNetworkScore: 10, + conductScore: 30, + activityScore: 35, + metrics: { + totalInvitesSent: 5, + totalInvitesAccepted: 3, + totalInvitesDeclined: 1, + averageInviteeReputation: 50, + totalReportsReceived: 0, + totalReportsResolved: 0, + totalUpvotes: 20, + totalDownvotes: 2, + totalPosts: 4, + totalComments: 15, + }, + }, + }); + track('userReputation', rep.id); + + expect(rep.overallScore).toBe(75); + expect(rep.metrics.totalInvitesSent).toBe(5); + }); + + it('should enforce unique userId constraint', async () => { + const user = await createTestUser('repuniq'); + const rep = await prisma.userReputation.create({ + data: { + userId: user.id, + overallScore: 10, + inviteNetworkScore: 0, + conductScore: 0, + activityScore: 0, + metrics: { + totalInvitesSent: 0, + totalInvitesAccepted: 0, + totalInvitesDeclined: 0, + averageInviteeReputation: 0, + totalReportsReceived: 0, + totalReportsResolved: 0, + totalUpvotes: 0, + totalDownvotes: 0, + totalPosts: 0, + totalComments: 0, + }, + }, + }); + track('userReputation', rep.id); + + await expect( + prisma.userReputation.create({ + data: { + userId: user.id, + overallScore: 20, + inviteNetworkScore: 0, + conductScore: 0, + activityScore: 0, + metrics: { + totalInvitesSent: 0, + totalInvitesAccepted: 0, + totalInvitesDeclined: 0, + averageInviteeReputation: 0, + totalReportsReceived: 0, + totalReportsResolved: 0, + totalUpvotes: 0, + totalDownvotes: 0, + totalPosts: 0, + totalComments: 0, + }, + }, + }) + ).rejects.toThrow(); + }); +}); + +// ============================================================================ +// 20. Domain → Content +// ============================================================================ +describe('Domain / Creator / Content / Collection Models', () => { + it('should create a Domain and Content linked via relation', async () => { + const domain = await prisma.domain.create({ + data: { key: `domain_${Date.now()}`, name: 'Example' }, + }); + track('domain', domain.id); + + const creator = await prisma.creator.create({ + data: { name: `Creator ${Date.now()}`, bio: 'Bio' }, + }); + track('creator', creator.id); + + const content = await prisma.content.create({ + data: { + title: 'Linked content', + creatorId: creator.id, + domainId: domain.id, + url: 'https://example.com', + }, + }); + track('content', content.id); + + const full = await prisma.content.findUnique({ + where: { id: content.id }, + include: { creator: true, domain: true }, + }); + expect(full!.creator.id).toBe(creator.id); + expect(full!.domain!.id).toBe(domain.id); + + // Domain → contents reverse relation + const domainFull = await prisma.domain.findUnique({ + where: { id: domain.id }, + include: { contents: true }, + }); + expect(domainFull!.contents.length).toBe(1); + }); + + it('should create a Collection linked to a user', async () => { + const user = await createTestUser('colluser'); + const group = await createTestGroup(user.id); + const post = await createTestPost(user.id, group.id); + + const coll = await prisma.collection.create({ + data: { + userId: user.id, + name: `Collection ${Date.now()}`, + description: 'Test collection', + postIds: [post.id], + }, + }); + track('collection', coll.id); + + const withColl = await prisma.user.findUnique({ + where: { id: user.id }, + include: { collections: true }, + }); + expect(withColl!.collections.length).toBe(1); + expect(withColl!.collections[0].postIds).toContain(post.id); + }); +}); + +// ============================================================================ +// 21. Deeper traversal — 3-level includes +// ============================================================================ +describe('3-Level Deep Relationship Traversal', () => { + it('should traverse user → posts → comments → commenter (back to user)', async () => { + const author = await createTestUser('deep3a'); + const commenter = await createTestUser('deep3b'); + const group = await createTestGroup(author.id); + const post = await createTestPost(author.id, group.id); + + const comment = await prisma.comment.create({ + data: { userId: commenter.id, postId: post.id, content: 'Deep comment' }, + }); + track('comment', comment.id); + + const authorDeep = await prisma.user.findUnique({ + where: { id: author.id }, + include: { + posts: { + include: { + comments: { + include: { user: true }, + }, + }, + }, + }, + }); + + expect(authorDeep!.posts[0].comments.length).toBe(1); + expect(authorDeep!.posts[0].comments[0].user.id).toBe(commenter.id); + expect(authorDeep!.posts[0].comments[0].user.username).toBe(commenter.username); + }); + + it('should traverse user → rooms → messages → reactions → reactor', async () => { + const user = await createTestUser('deep3msg'); + const reactor = await createTestUser('deep3react'); + const room = await prisma.messageRoom.create({ + data: { userIds: [user.id, reactor.id], messageType: 'USER' }, + }); + track('messageRoom', room.id); + + const msg = await prisma.message.create({ + data: { messageRoomId: room.id, userId: user.id, text: 'hi' }, + }); + track('message', msg.id); + + const reaction = await prisma.reaction.create({ + data: { userId: reactor.id, messageId: msg.id, emoji: '✨' }, + }); + track('reaction', reaction.id); + + const roomDeep = await prisma.messageRoom.findUnique({ + where: { id: room.id }, + include: { + messages: { + include: { + reactions: { + include: { user: true }, + }, + }, + }, + }, + }); + + expect(roomDeep!.messages[0].reactions[0].user.id).toBe(reactor.id); + expect(roomDeep!.messages[0].reactions[0].user.username).toBe(reactor.username); + }); +}); + +// ============================================================================ +// 22. Smoke — all 24 model delegates exist and are countable +// ============================================================================ +describe('All 24 models — delegate smoke test', () => { + it('should have working count() on every model', async () => { + const counts = await Promise.all([ + prisma.user.count(), + prisma.group.count(), + prisma.post.count(), + prisma.comment.count(), + prisma.quote.count(), + prisma.vote.count(), + prisma.voteLog.count(), + prisma.reaction.count(), + prisma.message.count(), + prisma.directMessage.count(), + prisma.messageRoom.count(), + prisma.notification.count(), + prisma.activity.count(), + prisma.roster.count(), + prisma.presence.count(), + prisma.typing.count(), + prisma.userInvite.count(), + prisma.userReport.count(), + prisma.botReport.count(), + prisma.userReputation.count(), + prisma.domain.count(), + prisma.creator.count(), + prisma.content.count(), + prisma.collection.count(), + ]); + expect(counts).toHaveLength(24); + counts.forEach((c) => expect(typeof c).toBe('number')); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/Comment.test.ts b/quotevote-backend/__tests__/unit/models/Comment.test.ts new file mode 100644 index 00000000..8fd46019 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/Comment.test.ts @@ -0,0 +1,71 @@ +import mongoose from 'mongoose'; +import Comment from '~/data/models/Comment'; + +describe('Comment Model', () => { + beforeAll(async () => { + // Connect to minimal mock or handle mock connection as in other models if needed. + // For unit testing the schema definition, this might suffice. + }); + + afterAll(async () => { + await mongoose.connection.close(); + }); + + describe('Schema Validation', () => { + it('should be invalid if required fields are empty', () => { + const comment = new Comment(); + const err = comment.validateSync(); + expect(err?.errors.content).toBeDefined(); + expect(err?.errors.userId).toBeDefined(); + expect(err?.errors.startWordIndex).toBeDefined(); + expect(err?.errors.endWordIndex).toBeDefined(); + }); + + it('should set default values', () => { + const comment = new Comment({ + content: 'Test content', + userId: new mongoose.Types.ObjectId(), + startWordIndex: 0, + endWordIndex: 5, + }); + expect(comment.deleted).toBe(false); + expect(comment.created).toBeDefined(); + }); + }); + + describe('Static Methods', () => { + it('findByPostId should return query ignoring deleted comments', async () => { + const postId = new mongoose.Types.ObjectId().toHexString(); + // Since it's a unit test without DB, we just ensure it builds the right query. + // We can spy on Comment.find + const findSpy = jest.spyOn(Comment, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Comment.findByPostId(postId); + + expect(findSpy).toHaveBeenCalledWith({ + postId, + deleted: { $ne: true }, + }); + + findSpy.mockRestore(); + }); + + it('findByUserId should return query ignoring deleted comments', async () => { + const userId = new mongoose.Types.ObjectId().toHexString(); + const findSpy = jest.spyOn(Comment, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Comment.findByUserId(userId); + + expect(findSpy).toHaveBeenCalledWith({ + userId, + deleted: { $ne: true }, + }); + + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/Message.test.ts b/quotevote-backend/__tests__/unit/models/Message.test.ts new file mode 100644 index 00000000..a053e44a --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/Message.test.ts @@ -0,0 +1,174 @@ +import mongoose from 'mongoose'; + +jest.mock('mongoose', () => { + const actualMongoose = jest.requireActual('mongoose'); + return { + ...actualMongoose, + model: jest.fn().mockReturnValue({ + create: jest.fn(), + find: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), + }), + models: {}, + }; +}); + +const MockMessage = mongoose.model('Message') as unknown as { + create: jest.Mock; + find: jest.Mock; + findById: jest.Mock; + findByIdAndUpdate: jest.Mock; + findByIdAndDelete: jest.Mock; +}; + +describe('Message Model', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockMessage = { + _id: 'm1', + messageRoomId: 'room1', + userId: 'user1', + userName: 'John', + title: 'Hello', + text: 'Hello world', + type: 'text', + mutation_type: 'create', + deleted: false, + readBy: ['user2'], + readByDetailed: [{ userId: 'user2', readAt: new Date() }], + deliveredTo: [{ userId: 'user2', deliveredAt: new Date() }], + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a message', async () => { + MockMessage.create.mockResolvedValue(mockMessage); + + const result = await MockMessage.create({ + messageRoomId: 'room1', + userId: 'user1', + text: 'Hello world', + }); + + expect(MockMessage.create).toHaveBeenCalledWith({ + messageRoomId: 'room1', + userId: 'user1', + text: 'Hello world', + }); + expect(result.text).toBe('Hello world'); + expect(result.deleted).toBe(false); + }); + }); + + describe('Read', () => { + it('should find messages by messageRoomId', async () => { + MockMessage.find.mockResolvedValue([mockMessage]); + + const result = await MockMessage.find({ messageRoomId: 'room1', deleted: { $ne: true } }); + + expect(MockMessage.find).toHaveBeenCalledWith({ + messageRoomId: 'room1', + deleted: { $ne: true }, + }); + expect(result).toHaveLength(1); + }); + + it('should find messages by userId', async () => { + MockMessage.find.mockResolvedValue([mockMessage]); + + const result = await MockMessage.find({ userId: 'user1' }); + + expect(result).toHaveLength(1); + expect(result[0].userId).toBe('user1'); + }); + + it('should find a message by id', async () => { + MockMessage.findById.mockResolvedValue(mockMessage); + + const result = await MockMessage.findById('m1'); + + expect(result).toEqual(mockMessage); + }); + + it('should return null for non-existent message', async () => { + MockMessage.findById.mockResolvedValue(null); + + const result = await MockMessage.findById('nonexistent'); + + expect(result).toBeNull(); + }); + + it('should include readByDetailed and deliveredTo', async () => { + MockMessage.findById.mockResolvedValue(mockMessage); + + const result = await MockMessage.findById('m1'); + + expect(result.readByDetailed).toHaveLength(1); + expect(result.deliveredTo).toHaveLength(1); + }); + }); + + describe('Update', () => { + it('should update message text', async () => { + const updated = { ...mockMessage, text: 'Updated message' }; + MockMessage.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockMessage.findByIdAndUpdate( + 'm1', + { text: 'Updated message' }, + { new: true } + ); + + expect(result.text).toBe('Updated message'); + }); + + it('should soft-delete a message', async () => { + const softDeleted = { ...mockMessage, deleted: true }; + MockMessage.findByIdAndUpdate.mockResolvedValue(softDeleted); + + const result = await MockMessage.findByIdAndUpdate( + 'm1', + { deleted: true }, + { new: true } + ); + + expect(result.deleted).toBe(true); + }); + + it('should mark message as read', async () => { + const read = { + ...mockMessage, + readBy: ['user2', 'user3'], + readByDetailed: [ + { userId: 'user2', readAt: new Date() }, + { userId: 'user3', readAt: new Date() }, + ], + }; + MockMessage.findByIdAndUpdate.mockResolvedValue(read); + + const result = await MockMessage.findByIdAndUpdate( + 'm1', + { $push: { readBy: 'user3' } }, + { new: true } + ); + + expect(result.readBy).toHaveLength(2); + }); + }); + + describe('Delete', () => { + it('should delete a message', async () => { + MockMessage.findByIdAndDelete.mockResolvedValue(mockMessage); + + const result = await MockMessage.findByIdAndDelete('m1'); + + expect(result).toEqual(mockMessage); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/MessageRoom.test.ts b/quotevote-backend/__tests__/unit/models/MessageRoom.test.ts new file mode 100644 index 00000000..1ea53511 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/MessageRoom.test.ts @@ -0,0 +1,167 @@ +import mongoose from 'mongoose'; + +jest.mock('mongoose', () => { + const actualMongoose = jest.requireActual('mongoose'); + return { + ...actualMongoose, + model: jest.fn().mockReturnValue({ + create: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), + }), + models: {}, + }; +}); + +const MockMessageRoom = mongoose.model('MessageRoom') as unknown as { + create: jest.Mock; + find: jest.Mock; + findOne: jest.Mock; + findById: jest.Mock; + findByIdAndUpdate: jest.Mock; + findByIdAndDelete: jest.Mock; +}; + +describe('MessageRoom Model', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockRoom = { + _id: 'room1', + users: ['user1', 'user2'], + postId: 'post1', + messageType: 'USER' as const, + title: 'Test Room', + avatar: 'avatar.png', + isDirect: true, + lastActivity: new Date(), + lastMessageTime: new Date(), + lastSeenMessages: new Map([['user1', 'msg1']]), + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a direct message room', async () => { + MockMessageRoom.create.mockResolvedValue(mockRoom); + + const result = await MockMessageRoom.create({ + users: ['user1', 'user2'], + messageType: 'USER', + isDirect: true, + }); + + expect(MockMessageRoom.create).toHaveBeenCalledWith({ + users: ['user1', 'user2'], + messageType: 'USER', + isDirect: true, + }); + expect(result.isDirect).toBe(true); + expect(result.users).toHaveLength(2); + }); + + it('should create a post-linked message room', async () => { + const postRoom = { ...mockRoom, _id: 'room2', messageType: 'POST' as const, isDirect: false }; + MockMessageRoom.create.mockResolvedValue(postRoom); + + const result = await MockMessageRoom.create({ + users: ['user1', 'user2'], + postId: 'post1', + messageType: 'POST', + }); + + expect(result.messageType).toBe('POST'); + expect(result.postId).toBe('post1'); + }); + }); + + describe('Read', () => { + it('should find rooms by userId', async () => { + MockMessageRoom.find.mockResolvedValue([mockRoom]); + + const result = await MockMessageRoom.find({ users: 'user1' }); + + expect(MockMessageRoom.find).toHaveBeenCalledWith({ users: 'user1' }); + expect(result).toHaveLength(1); + }); + + it('should find a room by postId', async () => { + MockMessageRoom.findOne.mockResolvedValue(mockRoom); + + const result = await MockMessageRoom.findOne({ postId: 'post1' }); + + expect(result).toEqual(mockRoom); + }); + + it('should find a direct room between two users', async () => { + MockMessageRoom.findOne.mockResolvedValue(mockRoom); + + const result = await MockMessageRoom.findOne({ + users: { $all: ['user1', 'user2'] }, + isDirect: true, + }); + + expect(result.isDirect).toBe(true); + }); + + it('should find a room by id', async () => { + MockMessageRoom.findById.mockResolvedValue(mockRoom); + + const result = await MockMessageRoom.findById('room1'); + + expect(result).toEqual(mockRoom); + }); + + it('should return null for non-existent room', async () => { + MockMessageRoom.findById.mockResolvedValue(null); + + const result = await MockMessageRoom.findById('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('Update', () => { + it('should update lastActivity', async () => { + const newDate = new Date(); + const updated = { ...mockRoom, lastActivity: newDate }; + MockMessageRoom.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockMessageRoom.findByIdAndUpdate( + 'room1', + { lastActivity: newDate }, + { new: true } + ); + + expect(result.lastActivity).toBe(newDate); + }); + + it('should update room title', async () => { + const updated = { ...mockRoom, title: 'New Title' }; + MockMessageRoom.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockMessageRoom.findByIdAndUpdate( + 'room1', + { title: 'New Title' }, + { new: true } + ); + + expect(result.title).toBe('New Title'); + }); + }); + + describe('Delete', () => { + it('should delete a message room', async () => { + MockMessageRoom.findByIdAndDelete.mockResolvedValue(mockRoom); + + const result = await MockMessageRoom.findByIdAndDelete('room1'); + + expect(result).toEqual(mockRoom); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/Post.test.ts b/quotevote-backend/__tests__/unit/models/Post.test.ts new file mode 100644 index 00000000..5b885cb9 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/Post.test.ts @@ -0,0 +1,259 @@ +import mongoose from 'mongoose'; + +jest.mock('mongoose', () => { + const actualMongoose = jest.requireActual('mongoose'); + return { + ...actualMongoose, + model: jest.fn().mockReturnValue({ + create: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), + findByUserId: jest.fn(), + findFeatured: jest.fn(), + }), + models: {}, + }; +}); + +const MockPost = mongoose.model('Post') as unknown as { + create: jest.Mock; + find: jest.Mock; + findOne: jest.Mock; + findById: jest.Mock; + findByIdAndUpdate: jest.Mock; + findByIdAndDelete: jest.Mock; + findByUserId: jest.Mock; + findFeatured: jest.Mock; +}; + +describe('Post Model', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockPost = { + _id: 'post1', + userId: 'user1', + groupId: 'group1', + title: 'Test Post', + text: 'This is a test quote for voting.', + url: 'https://example.com/article', + citationUrl: 'https://example.com/source', + upvotes: 5, + downvotes: 1, + reported: 0, + approved: 1, + approvedBy: ['mod1'], + rejectedBy: [], + reportedBy: [], + bookmarkedBy: ['user2'], + votedBy: ['user2', 'user3'], + enable_voting: true, + featuredSlot: 3, + messageRoomId: 'room1', + urlId: 'url1', + deleted: false, + dayPoints: 10, + pointTimestamp: new Date(), + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + // --------------------------------------------------------------------------- + // Create + // --------------------------------------------------------------------------- + + describe('Create', () => { + it('should create a post with required fields', async () => { + MockPost.create.mockResolvedValue(mockPost); + + const result = await MockPost.create({ + userId: 'user1', + groupId: 'group1', + title: 'Test Post', + text: 'This is a test quote for voting.', + }); + + expect(result.title).toBe('Test Post'); + expect(result.userId).toBe('user1'); + expect(result.groupId).toBe('group1'); + }); + + it('should create a post with optional fields', async () => { + MockPost.create.mockResolvedValue(mockPost); + + const result = await MockPost.create({ + userId: 'user1', + groupId: 'group1', + title: 'Test Post', + text: 'This is a test quote for voting.', + url: 'https://example.com/article', + citationUrl: 'https://example.com/source', + enable_voting: true, + }); + + expect(result.url).toBe('https://example.com/article'); + expect(result.citationUrl).toBe('https://example.com/source'); + expect(result.enable_voting).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // Read + // --------------------------------------------------------------------------- + + describe('Read', () => { + it('should find a post by id', async () => { + MockPost.findById.mockResolvedValue(mockPost); + + const result = await MockPost.findById('post1'); + + expect(result).toEqual(mockPost); + }); + + it('should return null for a non-existent post', async () => { + MockPost.findById.mockResolvedValue(null); + + const result = await MockPost.findById('nonexistent'); + + expect(result).toBeNull(); + }); + + it('should find posts by userId (static)', async () => { + MockPost.findByUserId.mockResolvedValue([mockPost]); + + const result = await MockPost.findByUserId('user1'); + + expect(MockPost.findByUserId).toHaveBeenCalledWith('user1'); + expect(result).toHaveLength(1); + expect(result[0].userId).toBe('user1'); + }); + + it('should return empty array for user with no posts', async () => { + MockPost.findByUserId.mockResolvedValue([]); + + const result = await MockPost.findByUserId('user999'); + + expect(result).toEqual([]); + }); + + it('should find featured posts (static)', async () => { + MockPost.findFeatured.mockResolvedValue([mockPost]); + + const result = await MockPost.findFeatured(12); + + expect(MockPost.findFeatured).toHaveBeenCalledWith(12); + expect(result).toHaveLength(1); + expect(result[0].featuredSlot).toBe(3); + }); + + it('should return empty array when no featured posts exist', async () => { + MockPost.findFeatured.mockResolvedValue([]); + + const result = await MockPost.findFeatured(); + + expect(result).toEqual([]); + }); + }); + + // --------------------------------------------------------------------------- + // Update + // --------------------------------------------------------------------------- + + describe('Update', () => { + it('should update post title', async () => { + const updated = { ...mockPost, title: 'Updated Title' }; + MockPost.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockPost.findByIdAndUpdate( + 'post1', + { title: 'Updated Title' }, + { new: true }, + ); + + expect(result.title).toBe('Updated Title'); + }); + + it('should update upvotes and downvotes', async () => { + const updated = { ...mockPost, upvotes: 10, downvotes: 2 }; + MockPost.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockPost.findByIdAndUpdate( + 'post1', + { upvotes: 10, downvotes: 2 }, + { new: true }, + ); + + expect(result.upvotes).toBe(10); + expect(result.downvotes).toBe(2); + }); + + it('should soft-delete a post', async () => { + const updated = { ...mockPost, deleted: true }; + MockPost.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockPost.findByIdAndUpdate( + 'post1', + { deleted: true }, + { new: true }, + ); + + expect(result.deleted).toBe(true); + }); + + it('should update featuredSlot', async () => { + const updated = { ...mockPost, featuredSlot: 7 }; + MockPost.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockPost.findByIdAndUpdate( + 'post1', + { featuredSlot: 7 }, + { new: true }, + ); + + expect(result.featuredSlot).toBe(7); + }); + }); + + // --------------------------------------------------------------------------- + // Delete + // --------------------------------------------------------------------------- + + describe('Delete', () => { + it('should delete a post', async () => { + MockPost.findByIdAndDelete.mockResolvedValue(mockPost); + + const result = await MockPost.findByIdAndDelete('post1'); + + expect(result).toEqual(mockPost); + }); + }); + + // --------------------------------------------------------------------------- + // Filtering / Listing + // --------------------------------------------------------------------------- + + describe('Filtering', () => { + it('should list posts filtered by groupId', async () => { + MockPost.find.mockResolvedValue([mockPost]); + + const result = await MockPost.find({ groupId: 'group1' }); + + expect(MockPost.find).toHaveBeenCalledWith({ groupId: 'group1' }); + expect(result).toHaveLength(1); + }); + + it('should list only non-deleted posts', async () => { + MockPost.find.mockResolvedValue([mockPost]); + + const result = await MockPost.find({ deleted: false }); + + expect(MockPost.find).toHaveBeenCalledWith({ deleted: false }); + expect(result[0].deleted).toBe(false); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/Presence.test.ts b/quotevote-backend/__tests__/unit/models/Presence.test.ts new file mode 100644 index 00000000..d1c25c55 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/Presence.test.ts @@ -0,0 +1,159 @@ +import mongoose from 'mongoose'; + +jest.mock('mongoose', () => { + const actualMongoose = jest.requireActual('mongoose'); + return { + ...actualMongoose, + model: jest.fn().mockReturnValue({ + create: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), + }), + models: {}, + }; +}); + +const MockPresence = mongoose.model('Presence') as unknown as { + create: jest.Mock; + find: jest.Mock; + findOne: jest.Mock; + findById: jest.Mock; + findByIdAndUpdate: jest.Mock; + findByIdAndDelete: jest.Mock; +}; + +describe('Presence Model', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockPresence = { + _id: 'p1', + userId: 'user1', + status: 'online' as const, + statusMessage: 'Working', + lastHeartbeat: new Date(), + lastSeen: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a presence record', async () => { + MockPresence.create.mockResolvedValue(mockPresence); + + const result = await MockPresence.create({ + userId: 'user1', + status: 'online', + }); + + expect(MockPresence.create).toHaveBeenCalledWith({ + userId: 'user1', + status: 'online', + }); + expect(result.status).toBe('online'); + }); + + it('should create a presence with status message', async () => { + MockPresence.create.mockResolvedValue(mockPresence); + + const result = await MockPresence.create({ + userId: 'user1', + status: 'online', + statusMessage: 'Working', + }); + + expect(result.statusMessage).toBe('Working'); + }); + }); + + describe('Read', () => { + it('should find presence by userId', async () => { + MockPresence.findOne.mockResolvedValue(mockPresence); + + const result = await MockPresence.findOne({ userId: 'user1' }); + + expect(MockPresence.findOne).toHaveBeenCalledWith({ userId: 'user1' }); + expect(result.status).toBe('online'); + }); + + it('should find all online users', async () => { + MockPresence.find.mockResolvedValue([mockPresence]); + + const result = await MockPresence.find({ status: 'online' }); + + expect(result).toHaveLength(1); + }); + + it('should find a presence by id', async () => { + MockPresence.findById.mockResolvedValue(mockPresence); + + const result = await MockPresence.findById('p1'); + + expect(result).toEqual(mockPresence); + }); + + it('should return null for non-existent presence', async () => { + MockPresence.findOne.mockResolvedValue(null); + + const result = await MockPresence.findOne({ userId: 'nonexistent' }); + + expect(result).toBeNull(); + }); + }); + + describe('Update', () => { + it('should update presence status', async () => { + const updated = { ...mockPresence, status: 'away' }; + MockPresence.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockPresence.findByIdAndUpdate( + 'p1', + { status: 'away' }, + { new: true } + ); + + expect(result.status).toBe('away'); + }); + + it('should update heartbeat', async () => { + const newHeartbeat = new Date(); + const updated = { ...mockPresence, lastHeartbeat: newHeartbeat }; + MockPresence.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockPresence.findByIdAndUpdate( + 'p1', + { lastHeartbeat: newHeartbeat }, + { new: true } + ); + + expect(result.lastHeartbeat).toBe(newHeartbeat); + }); + + it('should set status to offline', async () => { + const offline = { ...mockPresence, status: 'offline', lastSeen: new Date() }; + MockPresence.findByIdAndUpdate.mockResolvedValue(offline); + + const result = await MockPresence.findByIdAndUpdate( + 'p1', + { status: 'offline', lastSeen: new Date() }, + { new: true } + ); + + expect(result.status).toBe('offline'); + }); + }); + + describe('Delete', () => { + it('should delete a presence record', async () => { + MockPresence.findByIdAndDelete.mockResolvedValue(mockPresence); + + const result = await MockPresence.findByIdAndDelete('p1'); + + expect(result).toEqual(mockPresence); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/Quote.test.ts b/quotevote-backend/__tests__/unit/models/Quote.test.ts new file mode 100644 index 00000000..bc2ffbeb --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/Quote.test.ts @@ -0,0 +1,68 @@ +import mongoose from 'mongoose'; +import Quote from '~/data/models/Quote'; + +describe('Quote Model', () => { + afterAll(async () => { + await mongoose.connection.close(); + }); + + describe('Schema Validation', () => { + it('should be invalid if required fields are empty', () => { + const quote = new Quote(); + const err = quote.validateSync(); + expect(err?.errors.userId).toBeDefined(); + expect(err?.errors.postId).toBeDefined(); + expect(err?.errors.quote).toBeDefined(); + }); + + it('should set default values', () => { + const quote = new Quote({ + userId: new mongoose.Types.ObjectId(), + postId: new mongoose.Types.ObjectId(), + quote: 'This is a highlighted quote.', + }); + expect(quote.created).toBeDefined(); + }); + + it('should accept optional fields', () => { + const quote = new Quote({ + userId: new mongoose.Types.ObjectId(), + postId: new mongoose.Types.ObjectId(), + quote: 'Test quote text', + startWordIndex: 3, + endWordIndex: 10, + }); + expect(quote.startWordIndex).toBe(3); + expect(quote.endWordIndex).toBe(10); + }); + }); + + describe('Static Methods', () => { + it('findByPostId should query by postId and sort by created ascending', async () => { + const postId = new mongoose.Types.ObjectId().toHexString(); + const findSpy = jest.spyOn(Quote, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Quote.findByPostId(postId); + + expect(findSpy).toHaveBeenCalledWith({ postId }); + findSpy.mockRestore(); + }); + + it('findLatest should query all quotes, sort descending, and limit', async () => { + const limitMock = jest.fn().mockResolvedValue([]); + const sortMock = jest.fn().mockReturnValue({ limit: limitMock }); + const findSpy = jest.spyOn(Quote, 'find').mockReturnValue({ + sort: sortMock, + } as unknown as ReturnType); + + await Quote.findLatest(5); + + expect(findSpy).toHaveBeenCalledWith({}); + expect(sortMock).toHaveBeenCalledWith({ created: -1 }); + expect(limitMock).toHaveBeenCalledWith(5); + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/Reaction.test.ts b/quotevote-backend/__tests__/unit/models/Reaction.test.ts new file mode 100644 index 00000000..5c6308a0 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/Reaction.test.ts @@ -0,0 +1,141 @@ +import mongoose from 'mongoose'; + +jest.mock('mongoose', () => { + const actualMongoose = jest.requireActual('mongoose'); + return { + ...actualMongoose, + model: jest.fn().mockReturnValue({ + create: jest.fn(), + find: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), + }), + models: {}, + }; +}); + +const MockReaction = mongoose.model('Reaction') as unknown as { + create: jest.Mock; + find: jest.Mock; + findById: jest.Mock; + findByIdAndUpdate: jest.Mock; + findByIdAndDelete: jest.Mock; +}; + +describe('Reaction Model', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockReaction = { + _id: 'r1', + userId: 'user1', + messageId: 'msg1', + actionId: 'action1', + emoji: '👍', + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a reaction on a message', async () => { + MockReaction.create.mockResolvedValue(mockReaction); + + const result = await MockReaction.create({ + userId: 'user1', + messageId: 'msg1', + emoji: '👍', + }); + + expect(MockReaction.create).toHaveBeenCalledWith({ + userId: 'user1', + messageId: 'msg1', + emoji: '👍', + }); + expect(result.emoji).toBe('👍'); + }); + + it('should create a reaction on an action', async () => { + const actionReaction = { ...mockReaction, _id: 'r2', messageId: undefined }; + MockReaction.create.mockResolvedValue(actionReaction); + + const result = await MockReaction.create({ + userId: 'user1', + actionId: 'action1', + emoji: '❤️', + }); + + expect(result.actionId).toBe('action1'); + }); + }); + + describe('Read', () => { + it('should find reactions by messageId', async () => { + MockReaction.find.mockResolvedValue([mockReaction]); + + const result = await MockReaction.find({ messageId: 'msg1' }); + + expect(MockReaction.find).toHaveBeenCalledWith({ messageId: 'msg1' }); + expect(result).toHaveLength(1); + }); + + it('should find reactions by actionId', async () => { + MockReaction.find.mockResolvedValue([mockReaction]); + + const result = await MockReaction.find({ actionId: 'action1' }); + + expect(result).toHaveLength(1); + }); + + it('should find reactions by userId and messageId', async () => { + MockReaction.find.mockResolvedValue([mockReaction]); + + const result = await MockReaction.find({ userId: 'user1', messageId: 'msg1' }); + + expect(result).toHaveLength(1); + }); + + it('should find a reaction by id', async () => { + MockReaction.findById.mockResolvedValue(mockReaction); + + const result = await MockReaction.findById('r1'); + + expect(result).toEqual(mockReaction); + }); + + it('should return null for non-existent reaction', async () => { + MockReaction.findById.mockResolvedValue(null); + + const result = await MockReaction.findById('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('Update', () => { + it('should update reaction emoji', async () => { + const updated = { ...mockReaction, emoji: '😂' }; + MockReaction.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockReaction.findByIdAndUpdate( + 'r1', + { emoji: '😂' }, + { new: true } + ); + + expect(result.emoji).toBe('😂'); + }); + }); + + describe('Delete', () => { + it('should delete a reaction', async () => { + MockReaction.findByIdAndDelete.mockResolvedValue(mockReaction); + + const result = await MockReaction.findByIdAndDelete('r1'); + + expect(result).toEqual(mockReaction); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/Roster.test.ts b/quotevote-backend/__tests__/unit/models/Roster.test.ts new file mode 100644 index 00000000..2ca1f9c5 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/Roster.test.ts @@ -0,0 +1,168 @@ +import mongoose from 'mongoose'; + +jest.mock('mongoose', () => { + const actualMongoose = jest.requireActual('mongoose'); + return { + ...actualMongoose, + model: jest.fn().mockReturnValue({ + create: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), + }), + models: {}, + }; +}); + +const MockRoster = mongoose.model('Roster') as unknown as { + create: jest.Mock; + find: jest.Mock; + findOne: jest.Mock; + findById: jest.Mock; + findByIdAndUpdate: jest.Mock; + findByIdAndDelete: jest.Mock; +}; + +describe('Roster Model', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockRoster = { + _id: 'ros1', + userId: 'user1', + buddyId: 'user2', + status: 'pending' as const, + initiatedBy: 'user1', + created: new Date(), + updated: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a roster entry (buddy request)', async () => { + MockRoster.create.mockResolvedValue(mockRoster); + + const result = await MockRoster.create({ + userId: 'user1', + buddyId: 'user2', + initiatedBy: 'user1', + }); + + expect(MockRoster.create).toHaveBeenCalledWith({ + userId: 'user1', + buddyId: 'user2', + initiatedBy: 'user1', + }); + expect(result.status).toBe('pending'); + }); + }); + + describe('Read', () => { + it('should find roster entries by userId', async () => { + MockRoster.find.mockResolvedValue([mockRoster]); + + const result = await MockRoster.find({ + $or: [{ userId: 'user1' }, { buddyId: 'user1' }], + }); + + expect(result).toHaveLength(1); + }); + + it('should find pending requests for a user', async () => { + MockRoster.find.mockResolvedValue([mockRoster]); + + const result = await MockRoster.find({ buddyId: 'user2', status: 'pending' }); + + expect(MockRoster.find).toHaveBeenCalledWith({ buddyId: 'user2', status: 'pending' }); + expect(result).toHaveLength(1); + }); + + it('should find blocked users', async () => { + const blocked = { ...mockRoster, status: 'blocked' as const }; + MockRoster.find.mockResolvedValue([blocked]); + + const result = await MockRoster.find({ userId: 'user1', status: 'blocked' }); + + expect(result[0].status).toBe('blocked'); + }); + + it('should find a specific buddy pair', async () => { + MockRoster.findOne.mockResolvedValue(mockRoster); + + const result = await MockRoster.findOne({ userId: 'user1', buddyId: 'user2' }); + + expect(result).toEqual(mockRoster); + }); + + it('should find a roster entry by id', async () => { + MockRoster.findById.mockResolvedValue(mockRoster); + + const result = await MockRoster.findById('ros1'); + + expect(result).toEqual(mockRoster); + }); + + it('should return null for non-existent roster entry', async () => { + MockRoster.findById.mockResolvedValue(null); + + const result = await MockRoster.findById('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('Update', () => { + it('should accept a buddy request', async () => { + const accepted = { ...mockRoster, status: 'accepted' as const }; + MockRoster.findByIdAndUpdate.mockResolvedValue(accepted); + + const result = await MockRoster.findByIdAndUpdate( + 'ros1', + { status: 'accepted' }, + { new: true } + ); + + expect(result.status).toBe('accepted'); + }); + + it('should decline a buddy request', async () => { + const declined = { ...mockRoster, status: 'declined' as const }; + MockRoster.findByIdAndUpdate.mockResolvedValue(declined); + + const result = await MockRoster.findByIdAndUpdate( + 'ros1', + { status: 'declined' }, + { new: true } + ); + + expect(result.status).toBe('declined'); + }); + + it('should block a user', async () => { + const blocked = { ...mockRoster, status: 'blocked' as const }; + MockRoster.findByIdAndUpdate.mockResolvedValue(blocked); + + const result = await MockRoster.findByIdAndUpdate( + 'ros1', + { status: 'blocked' }, + { new: true } + ); + + expect(result.status).toBe('blocked'); + }); + }); + + describe('Delete', () => { + it('should delete a roster entry', async () => { + MockRoster.findByIdAndDelete.mockResolvedValue(mockRoster); + + const result = await MockRoster.findByIdAndDelete('ros1'); + + expect(result).toEqual(mockRoster); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/SolidConnection.test.ts b/quotevote-backend/__tests__/unit/models/SolidConnection.test.ts new file mode 100644 index 00000000..e3f3a126 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/SolidConnection.test.ts @@ -0,0 +1,132 @@ +import mongoose from 'mongoose'; + +jest.mock('mongoose', () => { + const actualMongoose = jest.requireActual('mongoose'); + return { + ...actualMongoose, + model: jest.fn().mockReturnValue({ + create: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), + }), + models: {}, + }; +}); + +const MockSolidConnection = mongoose.model('SolidConnection') as unknown as { + create: jest.Mock; + find: jest.Mock; + findOne: jest.Mock; + findById: jest.Mock; + findByIdAndUpdate: jest.Mock; + findByIdAndDelete: jest.Mock; +}; + +describe('SolidConnection Model', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockConnection = { + _id: 'sc1', + userId: 'user1', + webId: 'https://pod.example.com/profile/card#me', + issuer: 'https://pod.example.com', + encryptedTokens: 'encrypted_token_data', + scopes: ['openid', 'profile'], + idTokenClaims: { sub: 'user1' }, + tokenExpiry: new Date(), + resourceUris: { + profile: 'https://pod.example.com/profile', + preferences: 'https://pod.example.com/settings/prefs', + activityLedger: 'https://pod.example.com/activities', + }, + lastSyncAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a solid connection', async () => { + MockSolidConnection.create.mockResolvedValue(mockConnection); + + const result = await MockSolidConnection.create({ + userId: 'user1', + webId: 'https://pod.example.com/profile/card#me', + issuer: 'https://pod.example.com', + encryptedTokens: 'encrypted_token_data', + }); + + expect(MockSolidConnection.create).toHaveBeenCalled(); + expect(result.webId).toBe('https://pod.example.com/profile/card#me'); + }); + }); + + describe('Read', () => { + it('should find connection by userId', async () => { + MockSolidConnection.findOne.mockResolvedValue(mockConnection); + + const result = await MockSolidConnection.findOne({ userId: 'user1' }); + + expect(result).toEqual(mockConnection); + }); + + it('should find connection by id', async () => { + MockSolidConnection.findById.mockResolvedValue(mockConnection); + + const result = await MockSolidConnection.findById('sc1'); + + expect(result).toEqual(mockConnection); + }); + + it('should return null for non-existent connection', async () => { + MockSolidConnection.findOne.mockResolvedValue(null); + + const result = await MockSolidConnection.findOne({ userId: 'nonexistent' }); + + expect(result).toBeNull(); + }); + }); + + describe('Update', () => { + it('should update encrypted tokens', async () => { + const updated = { ...mockConnection, encryptedTokens: 'new_encrypted_data' }; + MockSolidConnection.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockSolidConnection.findByIdAndUpdate( + 'sc1', + { encryptedTokens: 'new_encrypted_data' }, + { new: true } + ); + + expect(result.encryptedTokens).toBe('new_encrypted_data'); + }); + + it('should update lastSyncAt', async () => { + const syncDate = new Date(); + const updated = { ...mockConnection, lastSyncAt: syncDate }; + MockSolidConnection.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockSolidConnection.findByIdAndUpdate( + 'sc1', + { lastSyncAt: syncDate }, + { new: true } + ); + + expect(result.lastSyncAt).toBe(syncDate); + }); + }); + + describe('Delete', () => { + it('should delete a solid connection', async () => { + MockSolidConnection.findByIdAndDelete.mockResolvedValue(mockConnection); + + const result = await MockSolidConnection.findByIdAndDelete('sc1'); + + expect(result).toEqual(mockConnection); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/Typing.test.ts b/quotevote-backend/__tests__/unit/models/Typing.test.ts new file mode 100644 index 00000000..c48dff2c --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/Typing.test.ts @@ -0,0 +1,155 @@ +import mongoose from 'mongoose'; + +jest.mock('mongoose', () => { + const actualMongoose = jest.requireActual('mongoose'); + return { + ...actualMongoose, + model: jest.fn().mockReturnValue({ + create: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), + }), + models: {}, + }; +}); + +const MockTyping = mongoose.model('Typing') as unknown as { + create: jest.Mock; + find: jest.Mock; + findOne: jest.Mock; + findById: jest.Mock; + findByIdAndUpdate: jest.Mock; + findByIdAndDelete: jest.Mock; +}; + +describe('Typing Model', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockTyping = { + _id: 't1', + messageRoomId: 'room1', + userId: 'user1', + isTyping: true, + timestamp: new Date(), + expiresAt: new Date(Date.now() + 10000), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a typing indicator', async () => { + MockTyping.create.mockResolvedValue(mockTyping); + + const result = await MockTyping.create({ + messageRoomId: 'room1', + userId: 'user1', + isTyping: true, + }); + + expect(MockTyping.create).toHaveBeenCalledWith({ + messageRoomId: 'room1', + userId: 'user1', + isTyping: true, + }); + expect(result.isTyping).toBe(true); + }); + }); + + describe('Read', () => { + it('should find typing indicators by room', async () => { + MockTyping.find.mockResolvedValue([mockTyping]); + + const result = await MockTyping.find({ messageRoomId: 'room1', isTyping: true }); + + expect(MockTyping.find).toHaveBeenCalledWith({ + messageRoomId: 'room1', + isTyping: true, + }); + expect(result).toHaveLength(1); + }); + + it('should find typing indicator for a specific user in a room', async () => { + MockTyping.findOne.mockResolvedValue(mockTyping); + + const result = await MockTyping.findOne({ + messageRoomId: 'room1', + userId: 'user1', + }); + + expect(result.userId).toBe('user1'); + expect(result.isTyping).toBe(true); + }); + + it('should find a typing indicator by id', async () => { + MockTyping.findById.mockResolvedValue(mockTyping); + + const result = await MockTyping.findById('t1'); + + expect(result).toEqual(mockTyping); + }); + + it('should return null when no typing indicator exists', async () => { + MockTyping.findOne.mockResolvedValue(null); + + const result = await MockTyping.findOne({ + messageRoomId: 'room1', + userId: 'nonexistent', + }); + + expect(result).toBeNull(); + }); + + it('should include expiresAt for TTL behavior', async () => { + MockTyping.findById.mockResolvedValue(mockTyping); + + const result = await MockTyping.findById('t1'); + + expect(result.expiresAt).toBeDefined(); + expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now() - 60000); + }); + }); + + describe('Update', () => { + it('should stop typing indicator', async () => { + const stopped = { ...mockTyping, isTyping: false }; + MockTyping.findByIdAndUpdate.mockResolvedValue(stopped); + + const result = await MockTyping.findByIdAndUpdate( + 't1', + { isTyping: false }, + { new: true } + ); + + expect(result.isTyping).toBe(false); + }); + + it('should refresh typing expiresAt', async () => { + const newExpiry = new Date(Date.now() + 10000); + const refreshed = { ...mockTyping, expiresAt: newExpiry }; + MockTyping.findByIdAndUpdate.mockResolvedValue(refreshed); + + const result = await MockTyping.findByIdAndUpdate( + 't1', + { expiresAt: newExpiry }, + { new: true } + ); + + expect(result.expiresAt).toBe(newExpiry); + }); + }); + + describe('Delete', () => { + it('should delete a typing indicator', async () => { + MockTyping.findByIdAndDelete.mockResolvedValue(mockTyping); + + const result = await MockTyping.findByIdAndDelete('t1'); + + expect(result).toEqual(mockTyping); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/Vote.test.ts b/quotevote-backend/__tests__/unit/models/Vote.test.ts new file mode 100644 index 00000000..ccc9de2a --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/Vote.test.ts @@ -0,0 +1,156 @@ +import mongoose from 'mongoose'; + +jest.mock('mongoose', () => { + const actualMongoose = jest.requireActual('mongoose'); + return { + ...actualMongoose, + model: jest.fn().mockReturnValue({ + create: jest.fn(), + find: jest.fn(), + findById: jest.fn(), + findByIdAndUpdate: jest.fn(), + findByIdAndDelete: jest.fn(), + }), + models: {}, + }; +}); + +const MockVote = mongoose.model('Vote') as unknown as { + create: jest.Mock; + find: jest.Mock; + findById: jest.Mock; + findByIdAndUpdate: jest.Mock; + findByIdAndDelete: jest.Mock; +}; + +describe('Vote Model', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockVote = { + _id: 'v1', + userId: 'user1', + postId: 'post1', + type: 'up' as const, + startWordIndex: 0, + endWordIndex: 5, + tags: ['insightful'], + content: 'Great point', + deleted: false, + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a vote', async () => { + MockVote.create.mockResolvedValue(mockVote); + + const result = await MockVote.create({ + userId: 'user1', + postId: 'post1', + type: 'up', + content: 'Great point', + }); + + expect(MockVote.create).toHaveBeenCalledWith({ + userId: 'user1', + postId: 'post1', + type: 'up', + content: 'Great point', + }); + expect(result.type).toBe('up'); + expect(result.deleted).toBe(false); + }); + + it('should create a downvote with tags', async () => { + const downvote = { ...mockVote, _id: 'v2', type: 'down' as const, tags: ['misleading'] }; + MockVote.create.mockResolvedValue(downvote); + + const result = await MockVote.create({ + userId: 'user1', + postId: 'post1', + type: 'down', + tags: ['misleading'], + }); + + expect(result.type).toBe('down'); + expect(result.tags).toContain('misleading'); + }); + }); + + describe('Read', () => { + it('should find votes by postId', async () => { + MockVote.find.mockResolvedValue([mockVote]); + + const result = await MockVote.find({ postId: 'post1', deleted: { $ne: true } }); + + expect(MockVote.find).toHaveBeenCalledWith({ postId: 'post1', deleted: { $ne: true } }); + expect(result).toHaveLength(1); + }); + + it('should find votes by userId', async () => { + MockVote.find.mockResolvedValue([mockVote]); + + const result = await MockVote.find({ userId: 'user1' }); + + expect(result).toHaveLength(1); + expect(result[0].userId).toBe('user1'); + }); + + it('should find a vote by id', async () => { + MockVote.findById.mockResolvedValue(mockVote); + + const result = await MockVote.findById('v1'); + + expect(result).toEqual(mockVote); + }); + + it('should return null for non-existent vote', async () => { + MockVote.findById.mockResolvedValue(null); + + const result = await MockVote.findById('nonexistent'); + + expect(result).toBeNull(); + }); + + it('should exclude soft-deleted votes', async () => { + MockVote.find.mockResolvedValue([]); + + const result = await MockVote.find({ postId: 'post1', deleted: { $ne: true } }); + + expect(result).toHaveLength(0); + }); + }); + + describe('Update', () => { + it('should update vote type', async () => { + const updated = { ...mockVote, type: 'down' }; + MockVote.findByIdAndUpdate.mockResolvedValue(updated); + + const result = await MockVote.findByIdAndUpdate('v1', { type: 'down' }, { new: true }); + + expect(result.type).toBe('down'); + }); + + it('should soft-delete a vote', async () => { + const softDeleted = { ...mockVote, deleted: true }; + MockVote.findByIdAndUpdate.mockResolvedValue(softDeleted); + + const result = await MockVote.findByIdAndUpdate('v1', { deleted: true }, { new: true }); + + expect(result.deleted).toBe(true); + }); + }); + + describe('Delete', () => { + it('should delete a vote', async () => { + MockVote.findByIdAndDelete.mockResolvedValue(mockVote); + + const result = await MockVote.findByIdAndDelete('v1'); + + expect(result).toEqual(mockVote); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Activity.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Activity.schema.test.ts new file mode 100644 index 00000000..4e3e8106 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Activity.schema.test.ts @@ -0,0 +1,46 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Activity from '~/data/models/Activity'; + +describe('Activity Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Activity(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.activityType).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Activity({ + userId: createObjectId(), + activityType: 'POSTED', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new Activity({ + userId: createObjectId(), + activityType: 'VOTED', + }); + expect(doc.created).toBeDefined(); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should accept optional fields', () => { + const postId = createObjectId(); + const voteId = createObjectId(); + const doc = new Activity({ + userId: createObjectId(), + activityType: 'VOTED', + postId, + voteId, + content: 'Voted on a post', + }); + expect(doc.postId).toEqual(postId); + expect(doc.content).toBe('Voted on a post'); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/BotReport.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/BotReport.schema.test.ts new file mode 100644 index 00000000..83a762bc --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/BotReport.schema.test.ts @@ -0,0 +1,31 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import BotReport from '~/data/models/BotReport'; + +describe('BotReport Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new BotReport(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.reporterId).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new BotReport({ + userId: createObjectId(), + reporterId: createObjectId(), + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default created date', () => { + const doc = new BotReport({ + userId: createObjectId(), + reporterId: createObjectId(), + }); + expect(doc.created).toBeInstanceOf(Date); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Collection.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Collection.schema.test.ts new file mode 100644 index 00000000..cf0cffa1 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Collection.schema.test.ts @@ -0,0 +1,42 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Collection from '~/data/models/Collection'; + +describe('Collection Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Collection(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.name).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Collection({ + userId: createObjectId(), + name: 'My Collection', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default created date', () => { + const doc = new Collection({ + userId: createObjectId(), + name: 'My Collection', + }); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should accept optional fields', () => { + const doc = new Collection({ + userId: createObjectId(), + name: 'My Collection', + description: 'A test collection', + postIds: [createObjectId(), createObjectId()], + }); + expect(doc.description).toBe('A test collection'); + expect(doc.postIds).toHaveLength(2); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Comment.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Comment.schema.test.ts new file mode 100644 index 00000000..a752a47b --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Comment.schema.test.ts @@ -0,0 +1,79 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Comment from '~/data/models/Comment'; + +describe('Comment Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Comment(); + const errors = getValidationErrors(doc); + expect(errors?.content).toBeDefined(); + expect(errors?.userId).toBeDefined(); + expect(errors?.startWordIndex).toBeDefined(); + expect(errors?.endWordIndex).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Comment({ + content: 'Test comment', + userId: createObjectId(), + startWordIndex: 0, + endWordIndex: 5, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new Comment({ + content: 'Test', + userId: createObjectId(), + startWordIndex: 0, + endWordIndex: 5, + }); + expect(doc.deleted).toBe(false); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should accept optional fields', () => { + const doc = new Comment({ + content: 'Test', + userId: createObjectId(), + startWordIndex: 0, + endWordIndex: 5, + postId: createObjectId(), + url: 'https://example.com', + reaction: 'like', + }); + expect(doc.postId).toBeDefined(); + expect(doc.url).toBe('https://example.com'); + expect(doc.reaction).toBe('like'); + }); + }); + + describe('Static Methods', () => { + it('findByPostId should filter deleted and sort by created', async () => { + const postId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Comment, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Comment.findByPostId(postId); + + expect(findSpy).toHaveBeenCalledWith({ postId, deleted: { $ne: true } }); + findSpy.mockRestore(); + }); + + it('findByUserId should filter deleted and sort by created', async () => { + const userId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Comment, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Comment.findByUserId(userId); + + expect(findSpy).toHaveBeenCalledWith({ userId, deleted: { $ne: true } }); + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Content.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Content.schema.test.ts new file mode 100644 index 00000000..92767d81 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Content.schema.test.ts @@ -0,0 +1,41 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Content from '~/data/models/Content'; + +describe('Content Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Content(); + const errors = getValidationErrors(doc); + expect(errors?.creatorId).toBeDefined(); + expect(errors?.title).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Content({ + creatorId: createObjectId(), + title: 'Test Content', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default created date', () => { + const doc = new Content({ + creatorId: createObjectId(), + title: 'Test Content', + }); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should accept optional fields', () => { + const doc = new Content({ + creatorId: createObjectId(), + title: 'Test Content', + domainId: createObjectId(), + url: 'https://example.com/article', + }); + expect(doc.url).toBe('https://example.com/article'); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Creator.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Creator.schema.test.ts new file mode 100644 index 00000000..aaacd628 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Creator.schema.test.ts @@ -0,0 +1,34 @@ +import { getValidationErrors, closeConnection } from './_helpers'; +import Creator from '~/data/models/Creator'; + +describe('Creator Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Creator(); + const errors = getValidationErrors(doc); + expect(errors?.name).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Creator({ name: 'Test Creator' }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default created date', () => { + const doc = new Creator({ name: 'Test Creator' }); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should accept optional fields', () => { + const doc = new Creator({ + name: 'Test Creator', + avatar: 'https://example.com/avatar.jpg', + bio: 'A test creator bio', + }); + expect(doc.avatar).toBe('https://example.com/avatar.jpg'); + expect(doc.bio).toBe('A test creator bio'); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Domain.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Domain.schema.test.ts new file mode 100644 index 00000000..f0c8439e --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Domain.schema.test.ts @@ -0,0 +1,29 @@ +import { getValidationErrors, closeConnection } from './_helpers'; +import Domain from '~/data/models/Domain'; + +describe('Domain Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Domain(); + const errors = getValidationErrors(doc); + expect(errors?.key).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Domain({ key: 'example.com' }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default created date', () => { + const doc = new Domain({ key: 'example.com' }); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should accept optional name', () => { + const doc = new Domain({ key: 'example.com', name: 'Example Domain' }); + expect(doc.name).toBe('Example Domain'); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Group.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Group.schema.test.ts new file mode 100644 index 00000000..e7cd0165 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Group.schema.test.ts @@ -0,0 +1,78 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Group from '~/data/models/Group'; + +describe('Group Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Group(); + const errors = getValidationErrors(doc); + expect(errors?.creatorId).toBeDefined(); + expect(errors?.title).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Group({ + creatorId: createObjectId(), + title: 'Test Group', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new Group({ + creatorId: createObjectId(), + title: 'Test Group', + }); + expect(doc.privacy).toBe('public'); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should reject invalid privacy enum', () => { + const doc = new Group({ + creatorId: createObjectId(), + title: 'Test Group', + privacy: 'invalid', + }); + const errors = getValidationErrors(doc); + expect(errors?.privacy).toBeDefined(); + }); + + it('should accept valid privacy values', () => { + for (const privacy of ['public', 'private', 'restricted']) { + const doc = new Group({ + creatorId: createObjectId(), + title: 'Test', + privacy, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + } + }); + + it('should accept optional fields', () => { + const doc = new Group({ + creatorId: createObjectId(), + title: 'Test', + adminIds: [createObjectId()], + allowedUserIds: [createObjectId()], + url: 'test-group', + description: 'A test group', + }); + expect(doc.adminIds).toHaveLength(1); + expect(doc.description).toBe('A test group'); + }); + }); + + describe('Static Methods', () => { + it('findByCreatorId should query by creatorId', async () => { + const creatorId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Group, 'find').mockResolvedValue([]); + + await Group.findByCreatorId(creatorId); + + expect(findSpy).toHaveBeenCalledWith({ creatorId }); + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Message.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Message.schema.test.ts new file mode 100644 index 00000000..bef020ad --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Message.schema.test.ts @@ -0,0 +1,77 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Message from '~/data/models/Message'; + +describe('Message Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Message(); + const errors = getValidationErrors(doc); + expect(errors?.messageRoomId).toBeDefined(); + expect(errors?.userId).toBeDefined(); + expect(errors?.text).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Message({ + messageRoomId: createObjectId(), + userId: createObjectId(), + text: 'Hello world', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new Message({ + messageRoomId: createObjectId(), + userId: createObjectId(), + text: 'Hello', + }); + expect(doc.deleted).toBe(false); + expect(doc.created).toBeInstanceOf(Date); + expect(doc.readByDetailed).toEqual([]); + expect(doc.deliveredTo).toEqual([]); + }); + + it('should accept optional fields', () => { + const doc = new Message({ + messageRoomId: createObjectId(), + userId: createObjectId(), + text: 'Hello', + userName: 'John', + title: 'Greeting', + type: 'text', + mutation_type: 'create', + }); + expect(doc.userName).toBe('John'); + expect(doc.title).toBe('Greeting'); + }); + + it('should accept embedded readByDetailed and deliveredTo', () => { + const doc = new Message({ + messageRoomId: createObjectId(), + userId: createObjectId(), + text: 'Hello', + readByDetailed: [{ userId: createObjectId(), readAt: new Date() }], + deliveredTo: [{ userId: createObjectId(), deliveredAt: new Date() }], + }); + expect(doc.readByDetailed).toHaveLength(1); + expect(doc.deliveredTo).toHaveLength(1); + }); + }); + + describe('Static Methods', () => { + it('findByRoomId should filter deleted and sort by created asc', async () => { + const messageRoomId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Message, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Message.findByRoomId(messageRoomId); + + expect(findSpy).toHaveBeenCalledWith({ messageRoomId, deleted: { $ne: true } }); + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/MessageRoom.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/MessageRoom.schema.test.ts new file mode 100644 index 00000000..ac908dba --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/MessageRoom.schema.test.ts @@ -0,0 +1,102 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import MessageRoom from '~/data/models/MessageRoom'; + +describe('MessageRoom Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new MessageRoom(); + const errors = getValidationErrors(doc); + expect(errors?.messageType).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new MessageRoom({ + users: [createObjectId()], + messageType: 'USER', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new MessageRoom({ + users: [createObjectId()], + messageType: 'USER', + }); + expect(doc.isDirect).toBe(false); + expect(doc.lastActivity).toBeInstanceOf(Date); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should reject invalid messageType enum', () => { + const doc = new MessageRoom({ + users: [createObjectId()], + messageType: 'INVALID', + }); + const errors = getValidationErrors(doc); + expect(errors?.messageType).toBeDefined(); + }); + + it('should accept valid messageType values', () => { + for (const messageType of ['USER', 'POST']) { + const doc = new MessageRoom({ + users: [createObjectId()], + messageType, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + } + }); + + it('should accept optional fields', () => { + const doc = new MessageRoom({ + users: [createObjectId(), createObjectId()], + messageType: 'POST', + postId: createObjectId(), + title: 'Room Title', + avatar: 'avatar.png', + isDirect: true, + }); + expect(doc.title).toBe('Room Title'); + expect(doc.isDirect).toBe(true); + }); + }); + + describe('Static Methods', () => { + it('findByUserId should query and sort by lastActivity desc', async () => { + const userId = createObjectId().toHexString(); + const findSpy = jest.spyOn(MessageRoom, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await MessageRoom.findByUserId(userId); + + expect(findSpy).toHaveBeenCalledWith({ users: userId }); + findSpy.mockRestore(); + }); + + it('findByPostId should use findOne', async () => { + const postId = createObjectId().toHexString(); + const findOneSpy = jest.spyOn(MessageRoom, 'findOne').mockResolvedValue(null); + + await MessageRoom.findByPostId(postId); + + expect(findOneSpy).toHaveBeenCalledWith({ postId }); + findOneSpy.mockRestore(); + }); + + it('findBetweenUsers should query $all with isDirect', async () => { + const userId1 = createObjectId().toHexString(); + const userId2 = createObjectId().toHexString(); + const findOneSpy = jest.spyOn(MessageRoom, 'findOne').mockResolvedValue(null); + + await MessageRoom.findBetweenUsers(userId1, userId2); + + expect(findOneSpy).toHaveBeenCalledWith({ + users: { $all: [userId1, userId2] }, + isDirect: true, + }); + findOneSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Notification.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Notification.schema.test.ts new file mode 100644 index 00000000..facef426 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Notification.schema.test.ts @@ -0,0 +1,47 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Notification from '~/data/models/Notification'; + +describe('Notification Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Notification(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.userIdBy).toBeDefined(); + expect(errors?.notificationType).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Notification({ + userId: createObjectId(), + userIdBy: createObjectId(), + notificationType: 'FOLLOW', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new Notification({ + userId: createObjectId(), + userIdBy: createObjectId(), + notificationType: 'UPVOTED', + }); + expect(doc.status).toBe('new'); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should accept optional fields', () => { + const doc = new Notification({ + userId: createObjectId(), + userIdBy: createObjectId(), + notificationType: 'COMMENTED', + postId: createObjectId(), + label: 'New comment on your post', + }); + expect(doc.label).toBe('New comment on your post'); + expect(doc.postId).toBeDefined(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Post.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Post.schema.test.ts new file mode 100644 index 00000000..44f1b661 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Post.schema.test.ts @@ -0,0 +1,127 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Post from '~/data/models/Post'; + +describe('Post Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Post(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.groupId).toBeDefined(); + expect(errors?.title).toBeDefined(); + expect(errors?.text).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Post({ + userId: createObjectId(), + groupId: createObjectId(), + title: 'Test Post', + text: 'Post body content', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new Post({ + userId: createObjectId(), + groupId: createObjectId(), + title: 'Test', + text: 'Body', + }); + expect(doc.downvotes).toBe(0); + expect(doc.upvotes).toBe(0); + expect(doc.reported).toBe(0); + expect(doc.deleted).toBe(false); + expect(doc.enable_voting).toBe(false); + expect(doc.dayPoints).toBe(0); + expect(doc.citationUrl).toBeNull(); + expect(doc.created).toBeInstanceOf(Date); + expect(doc.pointTimestamp).toBeInstanceOf(Date); + }); + + it('should accept optional fields', () => { + const doc = new Post({ + userId: createObjectId(), + groupId: createObjectId(), + title: 'Test', + text: 'Body', + url: 'https://example.com', + citationUrl: 'https://example.com/source', + enable_voting: true, + featuredSlot: 3, + messageRoomId: 'room1', + urlId: 'url1', + }); + expect(doc.url).toBe('https://example.com'); + expect(doc.enable_voting).toBe(true); + expect(doc.featuredSlot).toBe(3); + }); + + it('should reject featuredSlot below min', () => { + const doc = new Post({ + userId: createObjectId(), + groupId: createObjectId(), + title: 'Test', + text: 'Body', + featuredSlot: 0, + }); + const errors = getValidationErrors(doc); + expect(errors?.featuredSlot).toBeDefined(); + }); + + it('should reject featuredSlot above max', () => { + const doc = new Post({ + userId: createObjectId(), + groupId: createObjectId(), + title: 'Test', + text: 'Body', + featuredSlot: 13, + }); + const errors = getValidationErrors(doc); + expect(errors?.featuredSlot).toBeDefined(); + }); + }); + + describe('Static Methods', () => { + it('findByUserId should query by userId', async () => { + const userId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Post, 'find').mockResolvedValue([]); + + await Post.findByUserId(userId); + + expect(findSpy).toHaveBeenCalledWith({ userId }); + findSpy.mockRestore(); + }); + + it('findFeatured should query featured slots with sort and limit', async () => { + const limitMock = jest.fn().mockResolvedValue([]); + const sortMock = jest.fn().mockReturnValue({ limit: limitMock }); + const findSpy = jest.spyOn(Post, 'find').mockReturnValue({ + sort: sortMock, + } as unknown as ReturnType); + + await Post.findFeatured(6); + + expect(findSpy).toHaveBeenCalledWith({ featuredSlot: { $exists: true, $ne: null } }); + expect(sortMock).toHaveBeenCalledWith({ featuredSlot: 1 }); + expect(limitMock).toHaveBeenCalledWith(6); + findSpy.mockRestore(); + }); + + it('findFeatured should default limit to 12', async () => { + const limitMock = jest.fn().mockResolvedValue([]); + const sortMock = jest.fn().mockReturnValue({ limit: limitMock }); + const findSpy = jest.spyOn(Post, 'find').mockReturnValue({ + sort: sortMock, + } as unknown as ReturnType); + + await Post.findFeatured(); + + expect(limitMock).toHaveBeenCalledWith(12); + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Presence.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Presence.schema.test.ts new file mode 100644 index 00000000..d8566f9a --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Presence.schema.test.ts @@ -0,0 +1,78 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Presence from '~/data/models/Presence'; + +describe('Presence Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Presence(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Presence({ userId: createObjectId() }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new Presence({ userId: createObjectId() }); + expect(doc.status).toBe('offline'); + expect(doc.lastHeartbeat).toBeInstanceOf(Date); + expect(doc.lastSeen).toBeInstanceOf(Date); + }); + + it('should reject invalid status enum', () => { + const doc = new Presence({ + userId: createObjectId(), + status: 'invalid', + }); + const errors = getValidationErrors(doc); + expect(errors?.status).toBeDefined(); + }); + + it('should accept all valid status values', () => { + for (const status of ['online', 'away', 'dnd', 'offline', 'invisible']) { + const doc = new Presence({ userId: createObjectId(), status }); + expect(getValidationErrors(doc)).toBeUndefined(); + } + }); + + it('should accept optional statusMessage', () => { + const doc = new Presence({ + userId: createObjectId(), + statusMessage: 'Working on a feature', + }); + expect(doc.statusMessage).toBe('Working on a feature'); + }); + }); + + describe('Static Methods', () => { + it('findByUserId should use findOne', async () => { + const userId = createObjectId().toHexString(); + const findOneSpy = jest.spyOn(Presence, 'findOne').mockResolvedValue(null); + + await Presence.findByUserId(userId); + + expect(findOneSpy).toHaveBeenCalledWith({ userId }); + findOneSpy.mockRestore(); + }); + + it('updateHeartbeat should use findOneAndUpdate with upsert', async () => { + const userId = createObjectId().toHexString(); + const findOneAndUpdateSpy = jest + .spyOn(Presence, 'findOneAndUpdate') + .mockResolvedValue(null); + + await Presence.updateHeartbeat(userId); + + expect(findOneAndUpdateSpy).toHaveBeenCalledWith( + { userId }, + expect.objectContaining({ status: 'online' }), + expect.objectContaining({ upsert: true, new: true, setDefaultsOnInsert: true }) + ); + findOneAndUpdateSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Quote.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Quote.schema.test.ts new file mode 100644 index 00000000..fffd51c5 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Quote.schema.test.ts @@ -0,0 +1,75 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Quote from '~/data/models/Quote'; + +describe('Quote Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Quote(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.postId).toBeDefined(); + expect(errors?.quote).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Quote({ + userId: createObjectId(), + postId: createObjectId(), + quote: 'A highlighted quote', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default created date', () => { + const doc = new Quote({ + userId: createObjectId(), + postId: createObjectId(), + quote: 'Test', + }); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should accept optional fields', () => { + const doc = new Quote({ + userId: createObjectId(), + postId: createObjectId(), + quote: 'Test', + startWordIndex: 3, + endWordIndex: 10, + }); + expect(doc.startWordIndex).toBe(3); + expect(doc.endWordIndex).toBe(10); + }); + }); + + describe('Static Methods', () => { + it('findByPostId should query by postId', async () => { + const postId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Quote, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Quote.findByPostId(postId); + + expect(findSpy).toHaveBeenCalledWith({ postId }); + findSpy.mockRestore(); + }); + + it('findLatest should sort descending and limit', async () => { + const limitMock = jest.fn().mockResolvedValue([]); + const sortMock = jest.fn().mockReturnValue({ limit: limitMock }); + const findSpy = jest.spyOn(Quote, 'find').mockReturnValue({ + sort: sortMock, + } as unknown as ReturnType); + + await Quote.findLatest(5); + + expect(findSpy).toHaveBeenCalledWith({}); + expect(sortMock).toHaveBeenCalledWith({ created: -1 }); + expect(limitMock).toHaveBeenCalledWith(5); + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Reaction.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Reaction.schema.test.ts new file mode 100644 index 00000000..89434048 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Reaction.schema.test.ts @@ -0,0 +1,68 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Reaction from '~/data/models/Reaction'; + +describe('Reaction Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Reaction(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.emoji).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Reaction({ + userId: createObjectId(), + emoji: '👍', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default created date', () => { + const doc = new Reaction({ + userId: createObjectId(), + emoji: '👍', + }); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should accept optional messageId and actionId', () => { + const doc = new Reaction({ + userId: createObjectId(), + emoji: '❤️', + messageId: createObjectId(), + actionId: createObjectId(), + }); + expect(doc.messageId).toBeDefined(); + expect(doc.actionId).toBeDefined(); + }); + }); + + describe('Static Methods', () => { + it('findByActionId should query and sort by created desc', async () => { + const actionId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Reaction, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Reaction.findByActionId(actionId); + + expect(findSpy).toHaveBeenCalledWith({ actionId }); + findSpy.mockRestore(); + }); + + it('findByMessageId should query and sort by created desc', async () => { + const messageId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Reaction, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Reaction.findByMessageId(messageId); + + expect(findSpy).toHaveBeenCalledWith({ messageId }); + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Roster.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Roster.schema.test.ts new file mode 100644 index 00000000..727caa8d --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Roster.schema.test.ts @@ -0,0 +1,117 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Roster from '~/data/models/Roster'; + +describe('Roster Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Roster(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.buddyId).toBeDefined(); + expect(errors?.initiatedBy).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Roster({ + userId: createObjectId(), + buddyId: createObjectId(), + initiatedBy: createObjectId(), + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new Roster({ + userId: createObjectId(), + buddyId: createObjectId(), + initiatedBy: createObjectId(), + }); + expect(doc.status).toBe('pending'); + expect(doc.created).toBeInstanceOf(Date); + expect(doc.updated).toBeInstanceOf(Date); + }); + + it('should reject invalid status enum', () => { + const doc = new Roster({ + userId: createObjectId(), + buddyId: createObjectId(), + initiatedBy: createObjectId(), + status: 'invalid', + }); + const errors = getValidationErrors(doc); + expect(errors?.status).toBeDefined(); + }); + + it('should accept all valid status values', () => { + for (const status of ['pending', 'accepted', 'declined', 'blocked']) { + const doc = new Roster({ + userId: createObjectId(), + buddyId: createObjectId(), + initiatedBy: createObjectId(), + status, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + } + }); + }); + + describe('Pre-save Hook', () => { + // Note: The pre-save hook sets this.updated = new Date() on every save. + // Full integration testing of hooks requires mongodb-memory-server. + // Here we verify the schema defines the 'updated' field with a Date default. + it('should define updated field with Date default', () => { + const doc = new Roster({ + userId: createObjectId(), + buddyId: createObjectId(), + initiatedBy: createObjectId(), + }); + expect(doc.updated).toBeInstanceOf(Date); + }); + }); + + describe('Static Methods', () => { + it('findByUserId should use $or for userId and buddyId', async () => { + const userId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Roster, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Roster.findByUserId(userId); + + expect(findSpy).toHaveBeenCalledWith({ + $or: [{ userId }, { buddyId: userId }], + }); + findSpy.mockRestore(); + }); + + it('findPendingRequests should filter by buddyId and pending status', async () => { + const userId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Roster, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Roster.findPendingRequests(userId); + + expect(findSpy).toHaveBeenCalledWith({ + buddyId: userId, + status: 'pending', + }); + findSpy.mockRestore(); + }); + + it('findBlockedUsers should filter by userId and blocked status', async () => { + const userId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Roster, 'find').mockResolvedValue([]); + + await Roster.findBlockedUsers(userId); + + expect(findSpy).toHaveBeenCalledWith({ + userId, + status: 'blocked', + }); + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/SolidConnection.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/SolidConnection.schema.test.ts new file mode 100644 index 00000000..9edb648d --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/SolidConnection.schema.test.ts @@ -0,0 +1,59 @@ +import { SolidConnection } from '~/data/models/SolidConnection'; +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; + +describe('SolidConnection Schema', () => { + afterAll(async () => { + await closeConnection(); + }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new SolidConnection(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.webId).toBeDefined(); + expect(errors?.issuer).toBeDefined(); + expect(errors?.encryptedTokens).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new SolidConnection({ + userId: createObjectId(), + webId: 'https://pod.example.com/profile/card#me', + issuer: 'https://pod.example.com', + encryptedTokens: 'encrypted_data_here', + }); + const errors = getValidationErrors(doc); + expect(errors).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new SolidConnection({ + userId: createObjectId(), + webId: 'https://pod.example.com/profile/card#me', + issuer: 'https://pod.example.com', + encryptedTokens: 'encrypted_data_here', + }); + expect(doc.scopes).toEqual([]); + expect(doc.idTokenClaims).toEqual({}); + }); + + it('should accept optional fields', () => { + const doc = new SolidConnection({ + userId: createObjectId(), + webId: 'https://pod.example.com/profile/card#me', + issuer: 'https://pod.example.com', + encryptedTokens: 'encrypted_data_here', + scopes: ['openid', 'profile'], + tokenExpiry: new Date(), + resourceUris: { + profile: 'https://pod.example.com/profile', + preferences: 'https://pod.example.com/settings', + }, + lastSyncAt: new Date(), + }); + expect(doc.scopes).toEqual(['openid', 'profile']); + expect(doc.resourceUris?.profile).toBe('https://pod.example.com/profile'); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Typing.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Typing.schema.test.ts new file mode 100644 index 00000000..a099ac2a --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Typing.schema.test.ts @@ -0,0 +1,74 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Typing from '~/data/models/Typing'; + +describe('Typing Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Typing(); + const errors = getValidationErrors(doc); + expect(errors?.messageRoomId).toBeDefined(); + expect(errors?.userId).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Typing({ + messageRoomId: createObjectId(), + userId: createObjectId(), + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new Typing({ + messageRoomId: createObjectId(), + userId: createObjectId(), + }); + expect(doc.isTyping).toBe(true); + expect(doc.timestamp).toBeInstanceOf(Date); + }); + + it('should define expiresAt field as Date', () => { + // The pre-save hook sets expiresAt = Date.now() + 10000. + // Full hook testing requires mongodb-memory-server. + // Here we verify the field accepts Date values. + const doc = new Typing({ + messageRoomId: createObjectId(), + userId: createObjectId(), + expiresAt: new Date(Date.now() + 10000), + }); + expect(doc.expiresAt).toBeInstanceOf(Date); + }); + }); + + describe('Pre-save Hook', () => { + // Note: The pre-save hook sets this.expiresAt = new Date(Date.now() + 10 * 1000). + // This provides TTL-based auto-cleanup of stale typing indicators. + // Full integration testing requires mongodb-memory-server. + it('should have expiresAt field available for TTL index', () => { + const future = new Date(Date.now() + 10000); + const doc = new Typing({ + messageRoomId: createObjectId(), + userId: createObjectId(), + expiresAt: future, + }); + expect(doc.expiresAt!.getTime()).toBeGreaterThan(Date.now() - 1000); + }); + }); + + describe('Static Methods', () => { + it('findByRoomId should query by messageRoomId and isTyping=true', async () => { + const messageRoomId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Typing, 'find').mockResolvedValue([]); + + await Typing.findByRoomId(messageRoomId); + + expect(findSpy).toHaveBeenCalledWith({ + messageRoomId, + isTyping: true, + }); + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/User.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/User.schema.test.ts new file mode 100644 index 00000000..e524d49c --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/User.schema.test.ts @@ -0,0 +1,201 @@ +import * as bcrypt from 'bcryptjs'; +import { getValidationErrors, closeConnection } from './_helpers'; +import User from '~/data/models/User'; + +describe('User Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new User(); + const errors = getValidationErrors(doc); + expect(errors?.username).toBeDefined(); + expect(errors?.email).toBeDefined(); + expect(errors?.password).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new User({ + username: 'testuser', + email: 'test@example.com', + password: 'password123', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new User({ + username: 'testuser', + email: 'test@example.com', + password: 'password123', + }); + expect(doc.plan).toBe('personal'); + expect(doc.tokens).toBe(0); + expect(doc.contributorBadge).toBe(false); + expect(doc.admin).toBe(false); + expect(doc.emailVerified).toBe(false); + expect(doc.isModerator).toBe(false); + expect(doc.upvotes).toBe(0); + expect(doc.downvotes).toBe(0); + expect(doc.accountStatus).toBe('active'); + expect(doc.botReports).toBe(0); + expect(doc.favorited).toEqual([]); + expect(doc.joined).toBeInstanceOf(Date); + }); + + it('should set default reputation scores', () => { + const doc = new User({ + username: 'testuser', + email: 'test@example.com', + password: 'password123', + }); + expect(doc.reputation?.overallScore).toBe(0); + expect(doc.reputation?.inviteNetworkScore).toBe(0); + expect(doc.reputation?.conductScore).toBe(0); + expect(doc.reputation?.activityScore).toBe(0); + expect(doc.reputation?.metrics?.totalPosts).toBe(0); + expect(doc.reputation?.metrics?.totalComments).toBe(0); + }); + + it('should lowercase and trim email', () => { + const doc = new User({ + username: 'testuser', + email: ' TEST@EXAMPLE.COM ', + password: 'password123', + }); + expect(doc.email).toBe('test@example.com'); + }); + + it('should trim username', () => { + const doc = new User({ + username: ' testuser ', + email: 'test@example.com', + password: 'password123', + }); + expect(doc.username).toBe('testuser'); + }); + + it('should reject invalid accountStatus enum', () => { + const doc = new User({ + username: 'testuser', + email: 'test@example.com', + password: 'password123', + accountStatus: 'invalid', + }); + const errors = getValidationErrors(doc); + expect(errors?.accountStatus).toBeDefined(); + }); + + it('should accept valid accountStatus values', () => { + for (const accountStatus of ['active', 'disabled']) { + const doc = new User({ + username: 'testuser', + email: 'test@example.com', + password: 'password123', + accountStatus, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + } + }); + + it('should accept optional profile fields', () => { + const doc = new User({ + username: 'testuser', + email: 'test@example.com', + password: 'password123', + name: 'Test User', + bio: 'A test bio', + location: 'New York', + website: 'https://example.com', + companyName: 'TestCorp', + }); + expect(doc.name).toBe('Test User'); + expect(doc.bio).toBe('A test bio'); + expect(doc.location).toBe('New York'); + }); + + it('should accept avatar as object, string, or null', () => { + const docObj = new User({ + username: 'u1', email: 'a@b.com', password: 'p', + avatar: { url: 'https://example.com/img.jpg' }, + }); + expect(docObj.avatar).toEqual({ url: 'https://example.com/img.jpg' }); + + const docStr = new User({ + username: 'u2', email: 'b@b.com', password: 'p', + avatar: 'https://example.com/avatar.png', + }); + expect(docStr.avatar).toBe('https://example.com/avatar.png'); + + const docNull = new User({ + username: 'u3', email: 'c@b.com', password: 'p', + avatar: null, + }); + expect(docNull.avatar).toBeNull(); + }); + }); + + describe('Instance Method: comparePassword', () => { + it('should return true for matching password', async () => { + const plainPassword = 'testpassword123'; + const salt = await bcrypt.genSalt(4); + const hashedPassword = await bcrypt.hash(plainPassword, salt); + + const doc = new User({ + username: 'testuser', + email: 'test@example.com', + password: hashedPassword, + }); + + const isMatch = await doc.comparePassword(plainPassword); + expect(isMatch).toBe(true); + }); + + it('should return false for non-matching password', async () => { + const salt = await bcrypt.genSalt(4); + const hashedPassword = await bcrypt.hash('correctpassword', salt); + + const doc = new User({ + username: 'testuser', + email: 'test@example.com', + password: hashedPassword, + }); + + const isMatch = await doc.comparePassword('wrongpassword'); + expect(isMatch).toBe(false); + }); + }); + + describe('Pre-save Hook', () => { + // Note: The pre-save hook hashes the password using bcrypt when modified. + // Full hook integration testing requires mongodb-memory-server. + // The comparePassword tests above verify the bcrypt integration works. + it('should have bcrypt available for password hashing', async () => { + const plain = 'testPassword'; + const salt = await bcrypt.genSalt(10); + const hash = await bcrypt.hash(plain, salt); + const result = await bcrypt.compare(plain, hash); + expect(result).toBe(true); + }); + }); + + describe('Static Methods', () => { + it('findByUsername should use findOne', async () => { + const findOneSpy = jest.spyOn(User, 'findOne').mockResolvedValue(null); + + await User.findByUsername('testuser'); + + expect(findOneSpy).toHaveBeenCalledWith({ username: 'testuser' }); + findOneSpy.mockRestore(); + }); + + it('findByEmail should use findOne', async () => { + const findOneSpy = jest.spyOn(User, 'findOne').mockResolvedValue(null); + + await User.findByEmail('test@example.com'); + + expect(findOneSpy).toHaveBeenCalledWith({ email: 'test@example.com' }); + findOneSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/UserInvite.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/UserInvite.schema.test.ts new file mode 100644 index 00000000..29b65dde --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/UserInvite.schema.test.ts @@ -0,0 +1,52 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import UserInvite from '~/data/models/UserInvite'; + +describe('UserInvite Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new UserInvite(); + const errors = getValidationErrors(doc); + expect(errors?.email).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new UserInvite({ email: 'test@example.com' }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new UserInvite({ email: 'test@example.com' }); + expect(doc.status).toBe('pending'); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should lowercase and trim email', () => { + const doc = new UserInvite({ email: ' TEST@EXAMPLE.COM ' }); + expect(doc.email).toBe('test@example.com'); + }); + + it('should accept optional fields', () => { + const doc = new UserInvite({ + email: 'test@example.com', + invitedBy: createObjectId(), + code: 'INVITE123', + expiresAt: new Date(), + }); + expect(doc.code).toBe('INVITE123'); + expect(doc.invitedBy).toBeDefined(); + }); + }); + + describe('Static Methods', () => { + it('findByEmail should lowercase and use findOne', async () => { + const findOneSpy = jest.spyOn(UserInvite, 'findOne').mockResolvedValue(null); + + await UserInvite.findByEmail('TEST@EXAMPLE.COM'); + + expect(findOneSpy).toHaveBeenCalledWith({ email: 'test@example.com' }); + findOneSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/UserReport.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/UserReport.schema.test.ts new file mode 100644 index 00000000..71f2c11b --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/UserReport.schema.test.ts @@ -0,0 +1,137 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import UserReport from '~/data/models/UserReport'; + +describe('UserReport Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new UserReport(); + const errors = getValidationErrors(doc); + expect(errors?.reporterId).toBeDefined(); + expect(errors?.reportedUserId).toBeDefined(); + expect(errors?.reason).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason: 'spam', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason: 'spam', + }); + expect(doc.status).toBe('pending'); + expect(doc.severity).toBe('medium'); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should reject invalid reason enum', () => { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason: 'invalid_reason', + }); + const errors = getValidationErrors(doc); + expect(errors?.reason).toBeDefined(); + }); + + it('should accept all valid reason values', () => { + for (const reason of ['spam', 'harassment', 'inappropriate_content', 'fake_account', 'other']) { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + } + }); + + it('should reject invalid status enum', () => { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason: 'spam', + status: 'invalid_status', + }); + const errors = getValidationErrors(doc); + expect(errors?.status).toBeDefined(); + }); + + it('should accept all valid status values', () => { + for (const status of ['pending', 'reviewed', 'resolved', 'dismissed']) { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason: 'spam', + status, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + } + }); + + it('should reject invalid severity enum', () => { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason: 'spam', + severity: 'invalid_severity', + }); + const errors = getValidationErrors(doc); + expect(errors?.severity).toBeDefined(); + }); + + it('should accept all valid severity values', () => { + for (const severity of ['low', 'medium', 'high', 'critical']) { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason: 'spam', + severity, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + } + }); + + it('should accept optional description and adminNotes', () => { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason: 'harassment', + description: 'User sent abusive messages', + adminNotes: 'Reviewed and confirmed', + }); + expect(doc.description).toBe('User sent abusive messages'); + expect(doc.adminNotes).toBe('Reviewed and confirmed'); + }); + + it('should reject description exceeding maxlength', () => { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason: 'spam', + description: 'a'.repeat(501), + }); + const errors = getValidationErrors(doc); + expect(errors?.description).toBeDefined(); + }); + + it('should reject adminNotes exceeding maxlength', () => { + const doc = new UserReport({ + reporterId: createObjectId(), + reportedUserId: createObjectId(), + reason: 'spam', + adminNotes: 'a'.repeat(1001), + }); + const errors = getValidationErrors(doc); + expect(errors?.adminNotes).toBeDefined(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/UserReputation.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/UserReputation.schema.test.ts new file mode 100644 index 00000000..b2bef5b1 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/UserReputation.schema.test.ts @@ -0,0 +1,103 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import UserReputation from '~/data/models/UserReputation'; + +describe('UserReputation Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new UserReputation(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new UserReputation({ userId: createObjectId() }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default score values to 0', () => { + const doc = new UserReputation({ userId: createObjectId() }); + expect(doc.overallScore).toBe(0); + expect(doc.inviteNetworkScore).toBe(0); + expect(doc.conductScore).toBe(0); + expect(doc.activityScore).toBe(0); + }); + + it('should set default metric values to 0', () => { + const doc = new UserReputation({ userId: createObjectId() }); + expect(doc.metrics.totalInvitesSent).toBe(0); + expect(doc.metrics.totalInvitesAccepted).toBe(0); + expect(doc.metrics.totalInvitesDeclined).toBe(0); + expect(doc.metrics.averageInviteeReputation).toBe(0); + expect(doc.metrics.totalReportsReceived).toBe(0); + expect(doc.metrics.totalReportsResolved).toBe(0); + expect(doc.metrics.totalUpvotes).toBe(0); + expect(doc.metrics.totalDownvotes).toBe(0); + expect(doc.metrics.totalPosts).toBe(0); + expect(doc.metrics.totalComments).toBe(0); + }); + + it('should set default lastCalculated date', () => { + const doc = new UserReputation({ userId: createObjectId() }); + expect(doc.lastCalculated).toBeInstanceOf(Date); + }); + }); + + describe('Static Methods', () => { + it('findByUserId should use findOne', async () => { + const userId = createObjectId().toHexString(); + const findOneSpy = jest.spyOn(UserReputation, 'findOne').mockResolvedValue(null); + + await UserReputation.findByUserId(userId); + + expect(findOneSpy).toHaveBeenCalledWith({ userId }); + findOneSpy.mockRestore(); + }); + + it('calculateScore should call reputation util and findOneAndUpdate', async () => { + const userId = createObjectId().toHexString(); + const mockReputationData = { + overallScore: 100, + inviteNetworkScore: 50, + conductScore: 30, + activityScore: 20, + metrics: { + totalInvitesSent: 5, + totalInvitesAccepted: 3, + totalInvitesDeclined: 1, + averageInviteeReputation: 200, + totalReportsReceived: 0, + totalReportsResolved: 0, + totalUpvotes: 10, + totalDownvotes: 2, + totalPosts: 8, + totalComments: 15, + }, + lastCalculated: new Date(), + }; + + // Mock the dynamic import + jest.mock('~/data/resolvers/utils/reputation', () => ({ + calculateUserReputation: jest.fn().mockResolvedValue(mockReputationData), + }), { virtual: true }); + + const findOneAndUpdateSpy = jest + .spyOn(UserReputation, 'findOneAndUpdate') + .mockResolvedValue(null); + + await UserReputation.calculateScore(userId); + + expect(findOneAndUpdateSpy).toHaveBeenCalledWith( + { userId }, + expect.objectContaining({ + overallScore: 100, + inviteNetworkScore: 50, + }), + expect.objectContaining({ upsert: true, new: true }) + ); + + findOneAndUpdateSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/Vote.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/Vote.schema.test.ts new file mode 100644 index 00000000..9a8e7a6c --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/Vote.schema.test.ts @@ -0,0 +1,96 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import Vote from '~/data/models/Vote'; + +describe('Vote Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new Vote(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.postId).toBeDefined(); + expect(errors?.type).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new Vote({ + userId: createObjectId(), + postId: createObjectId(), + type: 'up', + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default values', () => { + const doc = new Vote({ + userId: createObjectId(), + postId: createObjectId(), + type: 'up', + }); + expect(doc.deleted).toBe(false); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should reject invalid type enum', () => { + const doc = new Vote({ + userId: createObjectId(), + postId: createObjectId(), + type: 'invalid', + }); + const errors = getValidationErrors(doc); + expect(errors?.type).toBeDefined(); + }); + + it('should accept valid enum values', () => { + for (const type of ['up', 'down']) { + const doc = new Vote({ + userId: createObjectId(), + postId: createObjectId(), + type, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + } + }); + + it('should accept optional fields', () => { + const doc = new Vote({ + userId: createObjectId(), + postId: createObjectId(), + type: 'up', + startWordIndex: 0, + endWordIndex: 5, + tags: ['insightful'], + content: 'Great point', + }); + expect(doc.tags).toEqual(['insightful']); + expect(doc.content).toBe('Great point'); + }); + }); + + describe('Static Methods', () => { + it('findByPostId should filter deleted and sort by created desc', async () => { + const postId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Vote, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Vote.findByPostId(postId); + + expect(findSpy).toHaveBeenCalledWith({ postId, deleted: { $ne: true } }); + findSpy.mockRestore(); + }); + + it('findByUserId should filter deleted and sort by created desc', async () => { + const userId = createObjectId().toHexString(); + const findSpy = jest.spyOn(Vote, 'find').mockReturnValue({ + sort: jest.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + await Vote.findByUserId(userId); + + expect(findSpy).toHaveBeenCalledWith({ userId, deleted: { $ne: true } }); + findSpy.mockRestore(); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/VoteLog.schema.test.ts b/quotevote-backend/__tests__/unit/models/schema/VoteLog.schema.test.ts new file mode 100644 index 00000000..615bf04b --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/VoteLog.schema.test.ts @@ -0,0 +1,87 @@ +import { createObjectId, getValidationErrors, closeConnection } from './_helpers'; +import VoteLog from '~/data/models/VoteLog'; + +describe('VoteLog Schema', () => { + afterAll(async () => { await closeConnection(); }); + + describe('Validation', () => { + it('should be invalid if required fields are empty', () => { + const doc = new VoteLog(); + const errors = getValidationErrors(doc); + expect(errors?.userId).toBeDefined(); + expect(errors?.voteId).toBeDefined(); + expect(errors?.postId).toBeDefined(); + expect(errors?.description).toBeDefined(); + expect(errors?.type).toBeDefined(); + expect(errors?.tokens).toBeDefined(); + }); + + it('should be valid with all required fields', () => { + const doc = new VoteLog({ + userId: createObjectId(), + voteId: createObjectId(), + postId: createObjectId(), + description: 'Upvoted a post', + type: 'up', + tokens: 10, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + }); + + it('should set default created date', () => { + const doc = new VoteLog({ + userId: createObjectId(), + voteId: createObjectId(), + postId: createObjectId(), + description: 'Upvoted', + type: 'up', + tokens: 10, + }); + expect(doc.created).toBeInstanceOf(Date); + }); + + it('should reject invalid type enum', () => { + const doc = new VoteLog({ + userId: createObjectId(), + voteId: createObjectId(), + postId: createObjectId(), + description: 'Voted', + type: 'invalid', + tokens: 10, + }); + const errors = getValidationErrors(doc); + expect(errors?.type).toBeDefined(); + }); + + it('should accept valid type values', () => { + for (const type of ['up', 'down']) { + const doc = new VoteLog({ + userId: createObjectId(), + voteId: createObjectId(), + postId: createObjectId(), + description: 'Voted', + type, + tokens: 5, + }); + expect(getValidationErrors(doc)).toBeUndefined(); + } + }); + + it('should accept optional fields', () => { + const doc = new VoteLog({ + userId: createObjectId(), + voteId: createObjectId(), + postId: createObjectId(), + description: 'Upvoted', + type: 'up', + tokens: 10, + title: 'Post Title', + author: 'John Doe', + action: 'upvote', + }); + expect(doc.title).toBe('Post Title'); + expect(doc.author).toBe('John Doe'); + expect(doc.action).toBe('upvote'); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/models/schema/_helpers.ts b/quotevote-backend/__tests__/unit/models/schema/_helpers.ts new file mode 100644 index 00000000..0e4d4f22 --- /dev/null +++ b/quotevote-backend/__tests__/unit/models/schema/_helpers.ts @@ -0,0 +1,23 @@ +import mongoose from 'mongoose'; + +/** + * Shared test helpers for schema validation tests. + */ + +/** Create a new ObjectId for test data */ +export function createObjectId(): mongoose.Types.ObjectId { + return new mongoose.Types.ObjectId(); +} + +/** Run validateSync() and return the errors map (or undefined if valid) */ +export function getValidationErrors(doc: mongoose.Document) { + const err = doc.validateSync(); + return err?.errors; +} + +/** Safely close mongoose connection if open */ +export async function closeConnection(): Promise { + if (mongoose.connection.readyState !== 0) { + await mongoose.connection.close(); + } +} diff --git a/quotevote-backend/__tests__/unit/prisma/Activity.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/Activity.prisma.test.ts new file mode 100644 index 00000000..c2cf573c --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/Activity.prisma.test.ts @@ -0,0 +1,88 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockActivity: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockActivity = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ activity: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma Activity Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'act1', + userId: 'user1', + postId: 'post1', + activityType: 'POSTED', + content: 'Created a post', + voteId: null, + commentId: null, + quoteId: null, + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create an activity', async () => { + mockActivity.create.mockResolvedValue(mockRecord); + + const result = await prisma.activity.create({ + data: { userId: 'user1', postId: 'post1', activityType: 'POSTED', content: 'Created a post' }, + }); + + expect(result.activityType).toBe('POSTED'); + expect(mockActivity.create).toHaveBeenCalledTimes(1); + }); + }); + + describe('Read', () => { + it('should find activities by userId', async () => { + mockActivity.findMany.mockResolvedValue([mockRecord]); + + const result = await prisma.activity.findMany({ where: { userId: 'user1' } }); + + expect(result).toHaveLength(1); + }); + + it('should find activity by id', async () => { + mockActivity.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.activity.findUnique({ where: { id: 'act1' } }); + + expect(result).toEqual(mockRecord); + }); + }); + + describe('Update', () => { + it('should update activity content', async () => { + const updated = { ...mockRecord, content: 'Updated content' }; + mockActivity.update.mockResolvedValue(updated); + + const result = await prisma.activity.update({ + where: { id: 'act1' }, + data: { content: 'Updated content' }, + }); + + expect(result.content).toBe('Updated content'); + }); + }); + + describe('Delete', () => { + it('should delete an activity', async () => { + mockActivity.delete.mockResolvedValue(mockRecord); + + const result = await prisma.activity.delete({ where: { id: 'act1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/BotReport.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/BotReport.prisma.test.ts new file mode 100644 index 00000000..7c82ad51 --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/BotReport.prisma.test.ts @@ -0,0 +1,69 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockBotReport: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockBotReport = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ botReport: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma BotReport Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'br1', + userId: 'user1', + reporterId: 'reporter1', + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a bot report', async () => { + mockBotReport.create.mockResolvedValue(mockRecord); + + const result = await prisma.botReport.create({ + data: { userId: 'user1', reporterId: 'reporter1' }, + }); + + expect(result.userId).toBe('user1'); + expect(result.reporterId).toBe('reporter1'); + }); + }); + + describe('Read', () => { + it('should find reports by userId', async () => { + mockBotReport.findMany.mockResolvedValue([mockRecord]); + + const result = await prisma.botReport.findMany({ where: { userId: 'user1' } }); + + expect(result).toHaveLength(1); + }); + + it('should find report by id', async () => { + mockBotReport.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.botReport.findUnique({ where: { id: 'br1' } }); + + expect(result).toEqual(mockRecord); + }); + }); + + describe('Delete', () => { + it('should delete a bot report', async () => { + mockBotReport.delete.mockResolvedValue(mockRecord); + + const result = await prisma.botReport.delete({ where: { id: 'br1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/Collection.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/Collection.prisma.test.ts new file mode 100644 index 00000000..2337f383 --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/Collection.prisma.test.ts @@ -0,0 +1,85 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockCollection: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockCollection = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ collection: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma Collection Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'col1', + userId: 'user1', + name: 'My Collection', + description: 'A test collection', + postIds: ['post1', 'post2'], + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a collection', async () => { + mockCollection.create.mockResolvedValue(mockRecord); + + const result = await prisma.collection.create({ + data: { userId: 'user1', name: 'My Collection', description: 'A test collection' }, + }); + + expect(result.name).toBe('My Collection'); + expect(result.userId).toBe('user1'); + }); + }); + + describe('Read', () => { + it('should find collections by userId', async () => { + mockCollection.findMany.mockResolvedValue([mockRecord]); + + const result = await prisma.collection.findMany({ where: { userId: 'user1' } }); + + expect(result).toHaveLength(1); + }); + + it('should find collection by id', async () => { + mockCollection.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.collection.findUnique({ where: { id: 'col1' } }); + + expect(result).toEqual(mockRecord); + }); + }); + + describe('Update', () => { + it('should update postIds array', async () => { + const updated = { ...mockRecord, postIds: ['post1', 'post2', 'post3'] }; + mockCollection.update.mockResolvedValue(updated); + + const result = await prisma.collection.update({ + where: { id: 'col1' }, + data: { postIds: ['post1', 'post2', 'post3'] }, + }); + + expect(result.postIds).toHaveLength(3); + }); + }); + + describe('Delete', () => { + it('should delete a collection', async () => { + mockCollection.delete.mockResolvedValue(mockRecord); + + const result = await prisma.collection.delete({ where: { id: 'col1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/Content.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/Content.prisma.test.ts new file mode 100644 index 00000000..b1d6b328 --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/Content.prisma.test.ts @@ -0,0 +1,85 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockContent: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockContent = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ content: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma Content Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'cnt1', + title: 'Test Content', + creatorId: 'creator1', + domainId: 'domain1', + url: 'https://example.com', + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create content', async () => { + mockContent.create.mockResolvedValue(mockRecord); + + const result = await prisma.content.create({ + data: { title: 'Test Content', creatorId: 'creator1' }, + }); + + expect(result.title).toBe('Test Content'); + expect(result.creatorId).toBe('creator1'); + }); + }); + + describe('Read', () => { + it('should find content by domainId', async () => { + mockContent.findMany.mockResolvedValue([mockRecord]); + + const result = await prisma.content.findMany({ where: { domainId: 'domain1' } }); + + expect(result).toHaveLength(1); + }); + + it('should find content by id', async () => { + mockContent.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.content.findUnique({ where: { id: 'cnt1' } }); + + expect(result).toEqual(mockRecord); + }); + }); + + describe('Update', () => { + it('should update content title', async () => { + const updated = { ...mockRecord, title: 'Updated Title' }; + mockContent.update.mockResolvedValue(updated); + + const result = await prisma.content.update({ + where: { id: 'cnt1' }, + data: { title: 'Updated Title' }, + }); + + expect(result.title).toBe('Updated Title'); + }); + }); + + describe('Delete', () => { + it('should delete content', async () => { + mockContent.delete.mockResolvedValue(mockRecord); + + const result = await prisma.content.delete({ where: { id: 'cnt1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/Creator.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/Creator.prisma.test.ts new file mode 100644 index 00000000..85b8b5d2 --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/Creator.prisma.test.ts @@ -0,0 +1,83 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockCreator: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockCreator = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ creator: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma Creator Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'cr1', + name: 'John Doe', + avatar: 'avatar.png', + bio: 'A content creator', + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a creator', async () => { + mockCreator.create.mockResolvedValue(mockRecord); + + const result = await prisma.creator.create({ + data: { name: 'John Doe', bio: 'A content creator' }, + }); + + expect(result.name).toBe('John Doe'); + }); + }); + + describe('Read', () => { + it('should find creator by name', async () => { + mockCreator.findFirst.mockResolvedValue(mockRecord); + + const result = await prisma.creator.findFirst({ where: { name: 'John Doe' } }); + + expect(result?.name).toBe('John Doe'); + }); + + it('should find creator by id', async () => { + mockCreator.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.creator.findUnique({ where: { id: 'cr1' } }); + + expect(result).toEqual(mockRecord); + }); + }); + + describe('Update', () => { + it('should update creator bio', async () => { + const updated = { ...mockRecord, bio: 'Updated bio' }; + mockCreator.update.mockResolvedValue(updated); + + const result = await prisma.creator.update({ + where: { id: 'cr1' }, + data: { bio: 'Updated bio' }, + }); + + expect(result.bio).toBe('Updated bio'); + }); + }); + + describe('Delete', () => { + it('should delete a creator', async () => { + mockCreator.delete.mockResolvedValue(mockRecord); + + const result = await prisma.creator.delete({ where: { id: 'cr1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/Domain.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/Domain.prisma.test.ts new file mode 100644 index 00000000..9a91b33c --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/Domain.prisma.test.ts @@ -0,0 +1,82 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockDomain: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockDomain = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ domain: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma Domain Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'dom1', + key: 'example.com', + name: 'Example', + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a domain with unique key', async () => { + mockDomain.create.mockResolvedValue(mockRecord); + + const result = await prisma.domain.create({ + data: { key: 'example.com', name: 'Example' }, + }); + + expect(result.key).toBe('example.com'); + }); + }); + + describe('Read', () => { + it('should find domain by unique key', async () => { + mockDomain.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.domain.findUnique({ where: { key: 'example.com' } }); + + expect(result?.key).toBe('example.com'); + }); + + it('should find domain by id', async () => { + mockDomain.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.domain.findUnique({ where: { id: 'dom1' } }); + + expect(result).toEqual(mockRecord); + }); + }); + + describe('Update', () => { + it('should update domain name', async () => { + const updated = { ...mockRecord, name: 'Updated Example' }; + mockDomain.update.mockResolvedValue(updated); + + const result = await prisma.domain.update({ + where: { id: 'dom1' }, + data: { name: 'Updated Example' }, + }); + + expect(result.name).toBe('Updated Example'); + }); + }); + + describe('Delete', () => { + it('should delete a domain', async () => { + mockDomain.delete.mockResolvedValue(mockRecord); + + const result = await prisma.domain.delete({ where: { id: 'dom1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/Group.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/Group.prisma.test.ts new file mode 100644 index 00000000..fc6080a0 --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/Group.prisma.test.ts @@ -0,0 +1,99 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockGroup: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockGroup = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ group: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma Group Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'grp1', + creatorId: 'user1', + adminIds: ['user1'], + allowedUserIds: [], + privacy: 'public', + title: 'Test Group', + url: null, + description: 'A test group', + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a group with privacy enum', async () => { + mockGroup.create.mockResolvedValue(mockRecord); + + const result = await prisma.group.create({ + data: { creatorId: 'user1', title: 'Test Group', privacy: 'public' }, + }); + + expect(result.privacy).toBe('public'); + expect(result.title).toBe('Test Group'); + }); + + it('should create a restricted group', async () => { + const restricted = { ...mockRecord, privacy: 'restricted' }; + mockGroup.create.mockResolvedValue(restricted); + + const result = await prisma.group.create({ + data: { creatorId: 'user1', title: 'Private Group', privacy: 'restricted' }, + }); + + expect(result.privacy).toBe('restricted'); + }); + }); + + describe('Read', () => { + it('should find groups by creatorId', async () => { + mockGroup.findMany.mockResolvedValue([mockRecord]); + + const result = await prisma.group.findMany({ where: { creatorId: 'user1' } }); + + expect(result).toHaveLength(1); + }); + + it('should find group by id', async () => { + mockGroup.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.group.findUnique({ where: { id: 'grp1' } }); + + expect(result).toEqual(mockRecord); + }); + }); + + describe('Update', () => { + it('should update group title', async () => { + const updated = { ...mockRecord, title: 'Updated Group' }; + mockGroup.update.mockResolvedValue(updated); + + const result = await prisma.group.update({ + where: { id: 'grp1' }, + data: { title: 'Updated Group' }, + }); + + expect(result.title).toBe('Updated Group'); + }); + }); + + describe('Delete', () => { + it('should delete a group', async () => { + mockGroup.delete.mockResolvedValue(mockRecord); + + const result = await prisma.group.delete({ where: { id: 'grp1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/Notification.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/Notification.prisma.test.ts new file mode 100644 index 00000000..716a4aa5 --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/Notification.prisma.test.ts @@ -0,0 +1,93 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockNotification: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockNotification = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ notification: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma Notification Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'notif1', + userId: 'user1', + userIdBy: 'user2', + label: 'New vote on your post', + status: 'new', + notificationType: 'UPVOTED', + postId: 'post1', + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a notification', async () => { + mockNotification.create.mockResolvedValue(mockRecord); + + const result = await prisma.notification.create({ + data: { + userId: 'user1', + userIdBy: 'user2', + label: 'New vote on your post', + notificationType: 'UPVOTED', + postId: 'post1', + }, + }); + + expect(result.notificationType).toBe('UPVOTED'); + expect(result.status).toBe('new'); + }); + }); + + describe('Read', () => { + it('should find notifications by userId', async () => { + mockNotification.findMany.mockResolvedValue([mockRecord]); + + const result = await prisma.notification.findMany({ where: { userId: 'user1' } }); + + expect(result).toHaveLength(1); + }); + + it('should find notification by id', async () => { + mockNotification.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.notification.findUnique({ where: { id: 'notif1' } }); + + expect(result).toEqual(mockRecord); + }); + }); + + describe('Update', () => { + it('should update notification status to read', async () => { + const updated = { ...mockRecord, status: 'read' }; + mockNotification.update.mockResolvedValue(updated); + + const result = await prisma.notification.update({ + where: { id: 'notif1' }, + data: { status: 'read' }, + }); + + expect(result.status).toBe('read'); + }); + }); + + describe('Delete', () => { + it('should delete a notification', async () => { + mockNotification.delete.mockResolvedValue(mockRecord); + + const result = await prisma.notification.delete({ where: { id: 'notif1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/UserInvite.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/UserInvite.prisma.test.ts new file mode 100644 index 00000000..070c2b02 --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/UserInvite.prisma.test.ts @@ -0,0 +1,86 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockUserInvite: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockUserInvite = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ userInvite: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma UserInvite Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'inv1', + email: 'invite@example.com', + invitedById: 'user1', + code: 'ABC123', + status: 'pending', + created: new Date(), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create an invite', async () => { + mockUserInvite.create.mockResolvedValue(mockRecord); + + const result = await prisma.userInvite.create({ + data: { email: 'invite@example.com', invitedById: 'user1', code: 'ABC123' }, + }); + + expect(result.email).toBe('invite@example.com'); + expect(result.status).toBe('pending'); + }); + }); + + describe('Read', () => { + it('should find invite by code', async () => { + mockUserInvite.findFirst.mockResolvedValue(mockRecord); + + const result = await prisma.userInvite.findFirst({ where: { code: 'ABC123' } }); + + expect(result?.code).toBe('ABC123'); + }); + + it('should find invites by email', async () => { + mockUserInvite.findMany.mockResolvedValue([mockRecord]); + + const result = await prisma.userInvite.findMany({ where: { email: 'invite@example.com' } }); + + expect(result).toHaveLength(1); + }); + }); + + describe('Update', () => { + it('should update invite status to accepted', async () => { + const updated = { ...mockRecord, status: 'accepted' }; + mockUserInvite.update.mockResolvedValue(updated); + + const result = await prisma.userInvite.update({ + where: { id: 'inv1' }, + data: { status: 'accepted' }, + }); + + expect(result.status).toBe('accepted'); + }); + }); + + describe('Delete', () => { + it('should delete an invite', async () => { + mockUserInvite.delete.mockResolvedValue(mockRecord); + + const result = await prisma.userInvite.delete({ where: { id: 'inv1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/UserReputation.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/UserReputation.prisma.test.ts new file mode 100644 index 00000000..a81b4f1f --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/UserReputation.prisma.test.ts @@ -0,0 +1,127 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockUserReputation: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockUserReputation = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ userReputation: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma UserReputation Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'rep1', + userId: 'user1', + overallScore: 75.5, + inviteNetworkScore: 80.0, + conductScore: 90.0, + activityScore: 60.0, + metrics: { + totalInvitesSent: 10, + totalInvitesAccepted: 8, + totalInvitesDeclined: 2, + averageInviteeReputation: 70.0, + totalReportsReceived: 0, + totalReportsResolved: 0, + totalUpvotes: 50, + totalDownvotes: 5, + totalPosts: 20, + totalComments: 30, + }, + lastCalculated: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a reputation record with metrics', async () => { + mockUserReputation.create.mockResolvedValue(mockRecord); + + const result = await prisma.userReputation.create({ + data: { + userId: 'user1', + overallScore: 75.5, + metrics: { + totalInvitesSent: 10, + totalInvitesAccepted: 8, + totalInvitesDeclined: 2, + averageInviteeReputation: 70.0, + totalReportsReceived: 0, + totalReportsResolved: 0, + totalUpvotes: 50, + totalDownvotes: 5, + totalPosts: 20, + totalComments: 30, + }, + }, + }); + + expect(result.overallScore).toBe(75.5); + expect(result.metrics.totalUpvotes).toBe(50); + }); + }); + + describe('Read', () => { + it('should find reputation by unique userId', async () => { + mockUserReputation.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.userReputation.findUnique({ where: { userId: 'user1' } }); + + expect(result?.userId).toBe('user1'); + expect(result?.metrics.totalPosts).toBe(20); + }); + + it('should find reputation by id', async () => { + mockUserReputation.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.userReputation.findUnique({ where: { id: 'rep1' } }); + + expect(result).toEqual(mockRecord); + }); + }); + + describe('Update', () => { + it('should update reputation scores', async () => { + const updated = { ...mockRecord, overallScore: 85.0, activityScore: 75.0 }; + mockUserReputation.update.mockResolvedValue(updated); + + const result = await prisma.userReputation.update({ + where: { userId: 'user1' }, + data: { overallScore: 85.0, activityScore: 75.0 }, + }); + + expect(result.overallScore).toBe(85.0); + expect(result.activityScore).toBe(75.0); + }); + + it('should upsert reputation', async () => { + mockUserReputation.upsert.mockResolvedValue(mockRecord); + + const result = await prisma.userReputation.upsert({ + where: { userId: 'user1' }, + create: { userId: 'user1', overallScore: 0, metrics: mockRecord.metrics }, + update: { overallScore: 75.5 }, + }); + + expect(result.userId).toBe('user1'); + }); + }); + + describe('Delete', () => { + it('should delete a reputation record', async () => { + mockUserReputation.delete.mockResolvedValue(mockRecord); + + const result = await prisma.userReputation.delete({ where: { id: 'rep1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/VoteLog.prisma.test.ts b/quotevote-backend/__tests__/unit/prisma/VoteLog.prisma.test.ts new file mode 100644 index 00000000..dc39d8c2 --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/VoteLog.prisma.test.ts @@ -0,0 +1,107 @@ +import { createMockPrismaModel, MockPrismaModel } from './_helpers'; + +let mockVoteLog: MockPrismaModel; + +jest.mock('@prisma/client', () => { + const model = createMockPrismaModel(); + mockVoteLog = model; + return { + PrismaClient: jest.fn().mockImplementation(() => ({ voteLog: model })), + }; +}); + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +describe('Prisma VoteLog Model', () => { + beforeEach(() => jest.clearAllMocks()); + + const mockRecord = { + id: 'vl1', + userId: 'user1', + voteId: 'vote1', + postId: 'post1', + title: 'Test Post', + author: 'Author Name', + description: 'Upvoted the post', + action: 'upvote', + type: 'up', + tokens: 5, + created: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + describe('Create', () => { + it('should create a vote log with all fields', async () => { + mockVoteLog.create.mockResolvedValue(mockRecord); + + const result = await prisma.voteLog.create({ + data: { + userId: 'user1', + voteId: 'vote1', + postId: 'post1', + description: 'Upvoted the post', + type: 'up', + tokens: 5, + }, + }); + + expect(result.type).toBe('up'); + expect(result.tokens).toBe(5); + expect(result.voteId).toBe('vote1'); + expect(result.description).toBe('Upvoted the post'); + }); + }); + + describe('Read', () => { + it('should find vote logs by postId', async () => { + mockVoteLog.findMany.mockResolvedValue([mockRecord]); + + const result = await prisma.voteLog.findMany({ where: { postId: 'post1' } }); + + expect(result).toHaveLength(1); + }); + + it('should find vote logs by userId', async () => { + mockVoteLog.findMany.mockResolvedValue([mockRecord]); + + const result = await prisma.voteLog.findMany({ where: { userId: 'user1' } }); + + expect(result).toHaveLength(1); + }); + + it('should find vote log by id', async () => { + mockVoteLog.findUnique.mockResolvedValue(mockRecord); + + const result = await prisma.voteLog.findUnique({ where: { id: 'vl1' } }); + + expect(result).toEqual(mockRecord); + }); + }); + + describe('Update', () => { + it('should update vote log tokens', async () => { + const updated = { ...mockRecord, tokens: 10 }; + mockVoteLog.update.mockResolvedValue(updated); + + const result = await prisma.voteLog.update({ + where: { id: 'vl1' }, + data: { tokens: 10 }, + }); + + expect(result.tokens).toBe(10); + }); + }); + + describe('Delete', () => { + it('should delete a vote log', async () => { + mockVoteLog.delete.mockResolvedValue(mockRecord); + + const result = await prisma.voteLog.delete({ where: { id: 'vl1' } }); + + expect(result).toEqual(mockRecord); + }); + }); +}); diff --git a/quotevote-backend/__tests__/unit/prisma/_helpers.ts b/quotevote-backend/__tests__/unit/prisma/_helpers.ts new file mode 100644 index 00000000..fa81f286 --- /dev/null +++ b/quotevote-backend/__tests__/unit/prisma/_helpers.ts @@ -0,0 +1,18 @@ +/** + * Shared helpers for Prisma mock-based CRUD tests + */ + +export function createMockPrismaModel() { + return { + create: jest.fn(), + findUnique: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + upsert: jest.fn(), + }; +} + +export type MockPrismaModel = ReturnType; diff --git a/quotevote-backend/app/data/models/Comment.ts b/quotevote-backend/app/data/models/Comment.ts index 57f37299..0440819c 100644 --- a/quotevote-backend/app/data/models/Comment.ts +++ b/quotevote-backend/app/data/models/Comment.ts @@ -1,21 +1,31 @@ import mongoose, { Schema } from 'mongoose'; import type { CommentDocument, CommentModel } from '~/types/mongoose'; -// Stub schema — will be expanded in issue 7.21 const CommentSchema = new Schema( { - userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, - postId: { type: Schema.Types.ObjectId, ref: 'Post', required: true }, content: { type: String, required: true }, - startWordIndex: { type: Number }, - endWordIndex: { type: Number }, - url: { type: String }, + userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + created: { type: Date, required: true, default: Date.now }, + startWordIndex: { type: Number, required: true }, + endWordIndex: { type: Number, required: true }, + postId: { type: Schema.Types.ObjectId, ref: 'Post', required: false }, + url: { type: String, required: false }, reaction: { type: String }, - created: { type: Date, default: Date.now }, + deleted: { type: Boolean, default: false }, }, { timestamps: true } ); +CommentSchema.index({ content: 'text' }); + +CommentSchema.statics.findByPostId = function (postId: string) { + return this.find({ postId, deleted: { $ne: true } }).sort({ created: 1 }); +}; + +CommentSchema.statics.findByUserId = function (userId: string) { + return this.find({ userId, deleted: { $ne: true } }).sort({ created: -1 }); +}; + const Comment = (mongoose.models.Comment as CommentModel) || mongoose.model('Comment', CommentSchema); diff --git a/quotevote-backend/app/data/models/Message.ts b/quotevote-backend/app/data/models/Message.ts index 6aa0dd19..a1f57166 100644 --- a/quotevote-backend/app/data/models/Message.ts +++ b/quotevote-backend/app/data/models/Message.ts @@ -1,23 +1,49 @@ import mongoose, { Schema } from 'mongoose'; import type { MessageDocument, MessageModel } from '~/types/mongoose'; -// Stub schema — will be expanded in issue 7.20 +const ReadByDetailedSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'User' }, + readAt: { type: Date }, + }, + { _id: false } +); + +const DeliveredToSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'User' }, + deliveredAt: { type: Date }, + }, + { _id: false } +); + const MessageSchema = new Schema( { messageRoomId: { type: Schema.Types.ObjectId, ref: 'MessageRoom', required: true }, userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, userName: { type: String }, title: { type: String }, - text: { type: String }, + text: { type: String, required: true }, type: { type: String }, mutation_type: { type: String }, deleted: { type: Boolean, default: false }, readBy: [{ type: Schema.Types.ObjectId, ref: 'User' }], - created: { type: Date, default: Date.now }, + readByDetailed: { type: [ReadByDetailedSchema], default: [] }, + deliveredTo: { type: [DeliveredToSchema], default: [] }, + created: { type: Date, required: true, default: Date.now }, }, { timestamps: true } ); +// Indexes +MessageSchema.index({ messageRoomId: 1, created: -1 }); +MessageSchema.index({ userId: 1 }); + +// Static methods +MessageSchema.statics.findByRoomId = function (messageRoomId: string) { + return this.find({ messageRoomId, deleted: { $ne: true } }).sort({ created: 1 }); +}; + const Message = (mongoose.models.Message as MessageModel) || mongoose.model('Message', MessageSchema); diff --git a/quotevote-backend/app/data/models/MessageRoom.ts b/quotevote-backend/app/data/models/MessageRoom.ts index 36b65673..213475c4 100644 --- a/quotevote-backend/app/data/models/MessageRoom.ts +++ b/quotevote-backend/app/data/models/MessageRoom.ts @@ -1,21 +1,42 @@ import mongoose, { Schema } from 'mongoose'; import type { MessageRoomDocument, MessageRoomModel } from '~/types/mongoose'; -// Stub schema — will be expanded in issue 7.20 const MessageRoomSchema = new Schema( { - users: [{ type: Schema.Types.ObjectId, ref: 'User' }], + users: [{ type: Schema.Types.ObjectId, ref: 'User', required: true }], postId: { type: Schema.Types.ObjectId, ref: 'Post' }, - messageType: { type: String, enum: ['USER', 'POST'] }, + messageType: { type: String, enum: ['USER', 'POST'], required: true }, title: { type: String }, avatar: { type: String }, + isDirect: { type: Boolean, default: false }, + lastActivity: { type: Date, default: Date.now }, lastMessageTime: { type: Date }, - lastActivity: { type: Date }, + lastSeenMessages: { type: Map, of: Schema.Types.ObjectId, default: new Map() }, created: { type: Date, default: Date.now }, }, { timestamps: true } ); +// Indexes +MessageRoomSchema.index({ users: 1, lastActivity: -1 }); +MessageRoomSchema.index({ postId: 1 }); + +// Static methods +MessageRoomSchema.statics.findByUserId = function (userId: string) { + return this.find({ users: userId }).sort({ lastActivity: -1 }); +}; + +MessageRoomSchema.statics.findByPostId = function (postId: string) { + return this.findOne({ postId }); +}; + +MessageRoomSchema.statics.findBetweenUsers = function (userId1: string, userId2: string) { + return this.findOne({ + users: { $all: [userId1, userId2] }, + isDirect: true, + }); +}; + const MessageRoom = (mongoose.models.MessageRoom as MessageRoomModel) || mongoose.model('MessageRoom', MessageRoomSchema); diff --git a/quotevote-backend/app/data/models/Post.ts b/quotevote-backend/app/data/models/Post.ts index d890a7e1..82c8a6d0 100644 --- a/quotevote-backend/app/data/models/Post.ts +++ b/quotevote-backend/app/data/models/Post.ts @@ -1,31 +1,71 @@ import mongoose, { Schema } from 'mongoose'; import type { PostDocument, PostModel } from '~/types/mongoose'; -// Stub schema — will be expanded in issue 7.20 const PostSchema = new Schema( { userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, - groupId: { type: Schema.Types.ObjectId, ref: 'Group' }, - title: { type: String }, - text: { type: String }, + groupId: { type: Schema.Types.ObjectId, ref: 'Group', required: true }, + title: { type: String, required: true }, + text: { type: String, required: true }, url: { type: String }, - citationUrl: { type: String }, - upvotes: { type: Number, default: 0 }, - downvotes: { type: Number, default: 0 }, - approvedBy: [{ type: String }], + citationUrl: { type: String, default: null }, + bookmarkedBy: [{ type: String }], rejectedBy: [{ type: String }], + approvedBy: [{ type: String }], + downvotes: { type: Number, default: 0 }, + upvotes: { type: Number, default: 0 }, + created: { type: Date, default: Date.now }, + reported: { type: Number, default: 0 }, reportedBy: [{ type: String }], - bookmarkedBy: [{ type: String }], - enable_voting: { type: Boolean, default: true }, - featuredSlot: { type: Number }, - deleted: { type: Boolean, default: false }, + approved: { type: Number }, + votedBy: [{ type: String }], dayPoints: { type: Number, default: 0 }, - pointTimestamp: { type: Date }, - created: { type: Date, default: Date.now }, + pointTimestamp: { type: Date, default: Date.now }, + featuredSlot: { + type: Number, + min: 1, + max: 12, + unique: true, + sparse: true, + }, + messageRoomId: { type: String }, + urlId: { type: String }, + deleted: { type: Boolean, default: false }, + enable_voting: { type: Boolean, default: false }, }, - { timestamps: true } + { timestamps: true }, ); +// ---------- Indexes ---------- + +// Full-text search on title and text +PostSchema.index({ title: 'text', text: 'text' }); + +// Featured-slot compound indexes (from legacy schema) +PostSchema.index({ featuredSlot: 1 }, { unique: true, sparse: true }); +PostSchema.index({ featuredSlot: 1, created: -1 }); +PostSchema.index({ featuredSlot: 1, pointTimestamp: -1 }); +PostSchema.index({ featuredSlot: 1, userId: 1 }); +PostSchema.index({ featuredSlot: 1, groupId: 1 }); +PostSchema.index({ featuredSlot: 1, deleted: 1 }); +PostSchema.index({ featuredSlot: 1, approved: 1 }); +PostSchema.index({ userId: 1, featuredSlot: 1 }); +PostSchema.index({ groupId: 1, featuredSlot: 1 }); + +// ---------- Static methods ---------- + +PostSchema.statics.findByUserId = function (userId: string) { + return this.find({ userId }); +}; + +PostSchema.statics.findFeatured = function (limit = 12) { + return this.find({ featuredSlot: { $exists: true, $ne: null } }) + .sort({ featuredSlot: 1 }) + .limit(limit); +}; + +// ---------- Model export ---------- + const Post = (mongoose.models.Post as PostModel) || mongoose.model('Post', PostSchema); diff --git a/quotevote-backend/app/data/models/Quote.ts b/quotevote-backend/app/data/models/Quote.ts new file mode 100644 index 00000000..12577d7e --- /dev/null +++ b/quotevote-backend/app/data/models/Quote.ts @@ -0,0 +1,30 @@ +import mongoose, { Schema } from 'mongoose'; +import type { QuoteDocument, QuoteModel } from '~/types/mongoose'; + +const QuoteSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + postId: { type: Schema.Types.ObjectId, ref: 'Post', required: true }, + quote: { type: String, required: true }, + startWordIndex: { type: Number }, + endWordIndex: { type: Number }, + created: { type: Date, required: true, default: Date.now }, + }, + { timestamps: true } +); + +QuoteSchema.index({ quote: 'text' }); + +QuoteSchema.statics.findByPostId = function (postId: string) { + return this.find({ postId }).sort({ created: 1 }); +}; + +QuoteSchema.statics.findLatest = function (limit: number) { + return this.find({}).sort({ created: -1 }).limit(limit); +}; + +const Quote = + (mongoose.models.Quote as QuoteModel) || + mongoose.model('Quote', QuoteSchema); + +export default Quote; diff --git a/quotevote-backend/app/data/models/Reaction.ts b/quotevote-backend/app/data/models/Reaction.ts new file mode 100644 index 00000000..ebeff309 --- /dev/null +++ b/quotevote-backend/app/data/models/Reaction.ts @@ -0,0 +1,33 @@ +import mongoose, { Schema } from 'mongoose'; +import type { ReactionDocument, ReactionModel } from '~/types/mongoose'; + +const ReactionSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + messageId: { type: Schema.Types.ObjectId, ref: 'Message' }, + actionId: { type: Schema.Types.ObjectId }, + emoji: { type: String, required: true }, + created: { type: Date, default: Date.now }, + }, + { timestamps: true } +); + +// Indexes +ReactionSchema.index({ messageId: 1 }); +ReactionSchema.index({ actionId: 1 }); +ReactionSchema.index({ userId: 1, messageId: 1 }); + +// Static methods +ReactionSchema.statics.findByActionId = function (actionId: string) { + return this.find({ actionId }).sort({ created: -1 }); +}; + +ReactionSchema.statics.findByMessageId = function (messageId: string) { + return this.find({ messageId }).sort({ created: -1 }); +}; + +const Reaction = + (mongoose.models.Reaction as ReactionModel) || + mongoose.model('Reaction', ReactionSchema); + +export default Reaction; diff --git a/quotevote-backend/app/data/models/Roster.ts b/quotevote-backend/app/data/models/Roster.ts new file mode 100644 index 00000000..359705ac --- /dev/null +++ b/quotevote-backend/app/data/models/Roster.ts @@ -0,0 +1,46 @@ +import mongoose, { Schema } from 'mongoose'; +import type { RosterDocument, RosterModel } from '~/types/mongoose'; + +const RosterSchema = new Schema( + { + userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true }, + buddyId: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true }, + status: { + type: String, + enum: ['pending', 'accepted', 'declined', 'blocked'], + default: 'pending', + }, + initiatedBy: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + created: { type: Date, default: Date.now }, + updated: { type: Date, default: Date.now }, + }, + { timestamps: true } +); + +// Compound indexes +RosterSchema.index({ userId: 1, buddyId: 1 }, { unique: true }); +RosterSchema.index({ userId: 1, status: 1 }); + +// Pre-save hook: update the 'updated' timestamp +RosterSchema.pre('save', function () { + this.updated = new Date(); +}); + +// Static methods +RosterSchema.statics.findByUserId = function (userId: string) { + return this.find({ $or: [{ userId }, { buddyId: userId }] }).sort({ updated: -1 }); +}; + +RosterSchema.statics.findPendingRequests = function (userId: string) { + return this.find({ buddyId: userId, status: 'pending' }).sort({ created: -1 }); +}; + +RosterSchema.statics.findBlockedUsers = function (userId: string) { + return this.find({ userId, status: 'blocked' }); +}; + +const Roster = + (mongoose.models.Roster as RosterModel) || + mongoose.model('Roster', RosterSchema); + +export default Roster; diff --git a/quotevote-backend/app/data/models/Typing.ts b/quotevote-backend/app/data/models/Typing.ts new file mode 100644 index 00000000..119ab8c9 --- /dev/null +++ b/quotevote-backend/app/data/models/Typing.ts @@ -0,0 +1,35 @@ +import mongoose, { Schema } from 'mongoose'; +import type { TypingDocument, TypingModel } from '~/types/mongoose'; + +const TypingSchema = new Schema( + { + messageRoomId: { type: Schema.Types.ObjectId, ref: 'MessageRoom', required: true, index: true }, + userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + isTyping: { type: Boolean, default: true }, + timestamp: { type: Date, default: Date.now }, + expiresAt: { type: Date, index: true }, + }, + { timestamps: true } +); + +// Compound unique index: one typing indicator per user per room +TypingSchema.index({ messageRoomId: 1, userId: 1 }, { unique: true }); + +// TTL index: auto-delete expired typing indicators +TypingSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +// Pre-save hook: set expiresAt to 10 seconds from now +TypingSchema.pre('save', function () { + this.expiresAt = new Date(Date.now() + 10 * 1000); +}); + +// Static methods +TypingSchema.statics.findByRoomId = function (messageRoomId: string) { + return this.find({ messageRoomId, isTyping: true }); +}; + +const Typing = + (mongoose.models.Typing as TypingModel) || + mongoose.model('Typing', TypingSchema); + +export default Typing; diff --git a/quotevote-backend/app/data/models/User.ts b/quotevote-backend/app/data/models/User.ts index 5494fdd9..df72c160 100644 --- a/quotevote-backend/app/data/models/User.ts +++ b/quotevote-backend/app/data/models/User.ts @@ -1,6 +1,6 @@ import mongoose, { Schema } from 'mongoose'; import * as bcrypt from 'bcryptjs'; -import type { UserDocument, UserModel } from '../../types/mongoose'; +import type { UserDocument, UserModel } from '~/types/mongoose'; const UserSchema = new Schema( { diff --git a/quotevote-backend/app/data/models/UserReport.ts b/quotevote-backend/app/data/models/UserReport.ts index 575308a9..345d9683 100644 --- a/quotevote-backend/app/data/models/UserReport.ts +++ b/quotevote-backend/app/data/models/UserReport.ts @@ -3,15 +3,35 @@ import type { UserReportDocument, UserReportModel } from '~/types/mongoose'; const UserReportSchema = new Schema( { - reportedUserId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, reporterId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, - reason: { type: String, required: true }, - status: { type: String, default: 'pending' }, + reportedUserId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + reason: { + type: String, + enum: ['spam', 'harassment', 'inappropriate_content', 'fake_account', 'other'], + required: true, + }, + description: { type: String, maxlength: 500 }, + status: { + type: String, + enum: ['pending', 'reviewed', 'resolved', 'dismissed'], + default: 'pending', + }, + severity: { + type: String, + enum: ['low', 'medium', 'high', 'critical'], + default: 'medium', + }, + adminNotes: { type: String, maxlength: 1000 }, created: { type: Date, default: Date.now }, }, { timestamps: true } ); +// Indexes +UserReportSchema.index({ reportedUserId: 1, status: 1 }); +UserReportSchema.index({ reporterId: 1 }); +UserReportSchema.index({ createdAt: -1 }); + const UserReport = (mongoose.models.UserReport as UserReportModel) || mongoose.model('UserReport', UserReportSchema); diff --git a/quotevote-backend/app/data/models/Vote.ts b/quotevote-backend/app/data/models/Vote.ts index f0da15fd..21910af5 100644 --- a/quotevote-backend/app/data/models/Vote.ts +++ b/quotevote-backend/app/data/models/Vote.ts @@ -1,7 +1,6 @@ import mongoose, { Schema } from 'mongoose'; import type { VoteDocument, VoteModel } from '~/types/mongoose'; -// Stub schema — will be expanded in issue 7.20 const VoteSchema = new Schema( { userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, @@ -12,10 +11,26 @@ const VoteSchema = new Schema( tags: [{ type: String }], content: { type: String }, created: { type: Date, default: Date.now }, + deleted: { type: Boolean, default: false }, }, { timestamps: true } ); +// Indexes +VoteSchema.index({ content: 'text' }); +VoteSchema.index({ postId: 1 }); +VoteSchema.index({ userId: 1 }); +VoteSchema.index({ postId: 1, userId: 1 }); + +// Static methods +VoteSchema.statics.findByPostId = function (postId: string) { + return this.find({ postId, deleted: { $ne: true } }).sort({ created: -1 }); +}; + +VoteSchema.statics.findByUserId = function (userId: string) { + return this.find({ userId, deleted: { $ne: true } }).sort({ created: -1 }); +}; + const Vote = (mongoose.models.Vote as VoteModel) || mongoose.model('Vote', VoteSchema); diff --git a/quotevote-backend/app/data/models/VoteLog.ts b/quotevote-backend/app/data/models/VoteLog.ts index 8900d316..8216c16c 100644 --- a/quotevote-backend/app/data/models/VoteLog.ts +++ b/quotevote-backend/app/data/models/VoteLog.ts @@ -4,13 +4,24 @@ import type { VoteLogDocument, VoteLogModel } from '~/types/mongoose'; const VoteLogSchema = new Schema( { userId: { type: Schema.Types.ObjectId, ref: 'User', required: true }, + voteId: { type: Schema.Types.ObjectId, ref: 'Vote', required: true }, postId: { type: Schema.Types.ObjectId, ref: 'Post', required: true }, + title: { type: String }, + author: { type: String }, + description: { type: String, required: true }, + action: { type: String }, type: { type: String, enum: ['up', 'down'], required: true }, + tokens: { type: Number, required: true }, created: { type: Date, default: Date.now }, }, { timestamps: true } ); +// Indexes +VoteLogSchema.index({ userId: 1 }); +VoteLogSchema.index({ voteId: 1 }); +VoteLogSchema.index({ postId: 1 }); + const VoteLog = (mongoose.models.VoteLog as VoteLogModel) || mongoose.model('VoteLog', VoteLogSchema); diff --git a/quotevote-backend/app/data/models/index.ts b/quotevote-backend/app/data/models/index.ts index 3b113d35..43a358c1 100644 --- a/quotevote-backend/app/data/models/index.ts +++ b/quotevote-backend/app/data/models/index.ts @@ -7,6 +7,7 @@ export { default as Activity } from './Activity'; export { default as Post } from './Post'; export { default as Vote } from './Vote'; export { default as Comment } from './Comment'; +export { default as Quote } from './Quote'; export { default as Message } from './Message'; export { default as MessageRoom } from './MessageRoom'; export { default as Notification } from './Notification'; @@ -21,3 +22,6 @@ export { default as UserInvite } from './UserInvite'; export { default as UserReputation } from './UserReputation'; export { default as UserReport } from './UserReport'; export { default as VoteLog } from './VoteLog'; +export { default as Reaction } from './Reaction'; +export { default as Roster } from './Roster'; +export { default as Typing } from './Typing'; diff --git a/quotevote-backend/app/types/common.ts b/quotevote-backend/app/types/common.ts index 6978338c..ee68372a 100644 --- a/quotevote-backend/app/types/common.ts +++ b/quotevote-backend/app/types/common.ts @@ -106,19 +106,24 @@ export interface User { export interface Post { _id: string; userId: string; - groupId?: string; - title?: string; - text?: string; + groupId: string; + title: string; + text: string; url?: string; citationUrl?: string; upvotes?: number; downvotes?: number; + reported?: number; + approved?: number; approvedBy?: string[]; rejectedBy?: string[]; reportedBy?: string[]; bookmarkedBy?: string[]; + votedBy?: string[]; enable_voting?: boolean; featuredSlot?: number; + messageRoomId?: string; + urlId?: string; deleted?: boolean; created: Date | string; updatedAt?: Date | string; @@ -131,12 +136,13 @@ export interface Post { export interface Comment { _id: string; userId: string; - postId: string; + postId?: string; content: string; - startWordIndex?: number; - endWordIndex?: number; + startWordIndex: number; + endWordIndex: number; url?: string; reaction?: string; + deleted?: boolean; created: Date | string; updatedAt?: Date | string; } @@ -154,15 +160,22 @@ export interface Vote { endWordIndex?: number; tags?: string[]; content?: string; + deleted?: boolean; created: Date | string; updatedAt?: Date | string; } export interface VoteLog { _id: string; - postId: string; userId: string; + voteId: string; + postId: string; + title?: string; + author?: string; + description: string; + action?: string; type: VoteType; + tokens: number; created: Date | string; } @@ -185,6 +198,16 @@ export interface Quote { // Message & Chat Types // ============================================================================ +export interface ReadByDetailedEntry { + userId: string; + readAt?: Date | string; +} + +export interface DeliveredToEntry { + userId: string; + deliveredAt?: Date | string; +} + export interface Message { _id: string; messageRoomId: string; @@ -196,6 +219,8 @@ export interface Message { mutation_type?: string; deleted?: boolean; readBy?: string[]; + readByDetailed?: ReadByDetailedEntry[]; + deliveredTo?: DeliveredToEntry[]; created: Date | string; updatedAt?: Date | string; } @@ -207,8 +232,10 @@ export interface MessageRoom { messageType?: MessageType; title?: string; avatar?: string; + isDirect?: boolean; lastMessageTime?: Date | string; lastActivity?: Date | string; + lastSeenMessages?: Record; unreadMessages?: number; created: Date | string; updatedAt?: Date | string; @@ -331,12 +358,19 @@ export interface UserInvite { expiresAt?: Date | string; } +export type ReportReason = 'spam' | 'harassment' | 'inappropriate_content' | 'fake_account' | 'other'; +export type ReportStatus = 'pending' | 'reviewed' | 'resolved' | 'dismissed'; +export type ReportSeverity = 'low' | 'medium' | 'high' | 'critical'; + export interface UserReport { _id: string; - reportedUserId: string; reporterId: string; - reason: string; - status?: string; + reportedUserId: string; + reason: ReportReason; + description?: string; + status?: ReportStatus; + severity?: ReportSeverity; + adminNotes?: string; created: Date | string; } diff --git a/quotevote-backend/app/types/mongoose.ts b/quotevote-backend/app/types/mongoose.ts index 95dce5fd..b57f1c0f 100644 --- a/quotevote-backend/app/types/mongoose.ts +++ b/quotevote-backend/app/types/mongoose.ts @@ -60,9 +60,9 @@ export interface PostDocument extends BaseDocument, Omit { _id: Types.ObjectId; userId: Types.ObjectId; - groupId?: Types.ObjectId; - dayPoints?: number; - pointTimestamp?: Date; + groupId: Types.ObjectId; + dayPoints: number; + pointTimestamp: Date; } export interface PostModel extends Model { @@ -75,12 +75,14 @@ export interface PostModel extends Model { // ============================================================================ export interface CommentDocument - extends BaseDocument, Omit { + extends BaseDocument, Omit { _id: Types.ObjectId; userId: Types.ObjectId; - postId: Types.ObjectId; + postId?: Types.ObjectId; + created: Date; } + export interface CommentModel extends Model { findByPostId(postId: string): Promise; findByUserId(userId: string): Promise; @@ -103,9 +105,10 @@ export interface VoteModel extends Model { } export interface VoteLogDocument - extends BaseDocument, Omit { + extends BaseDocument, Omit { _id: Types.ObjectId; userId: Types.ObjectId; + voteId: Types.ObjectId; postId: Types.ObjectId; } @@ -253,6 +256,7 @@ export interface PresenceModel extends Model { export interface TypingDocument extends Document, Omit { messageRoomId: Types.ObjectId; userId: Types.ObjectId; + expiresAt?: Date; } export interface TypingModel extends Model { diff --git a/quotevote-backend/jest.config.ts b/quotevote-backend/jest.config.ts index b8484568..87485871 100644 --- a/quotevote-backend/jest.config.ts +++ b/quotevote-backend/jest.config.ts @@ -14,7 +14,7 @@ const config: Config = { '^~/(.*)$': '/app/$1', }, - testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.next/'], + testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.next/', '_helpers\\.ts$', 'prisma-.*\\.test\\.ts$'], modulePathIgnorePatterns: ['/dist/'], setupFilesAfterEnv: ['/jest.setup.ts'], diff --git a/quotevote-backend/package.json b/quotevote-backend/package.json index 27137037..ae309555 100644 --- a/quotevote-backend/package.json +++ b/quotevote-backend/package.json @@ -8,6 +8,7 @@ "build": "tsc", "start": "node dist/app/server.js", "test": "jest --passWithNoTests", + "test:prisma": "tsx scripts/check-replica-set.ts && jest --testPathPattern='prisma-.*\\.test\\.ts$' --testPathIgnorePatterns='[]'", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", @@ -16,9 +17,12 @@ "prisma:generate": "prisma generate --schema=./prisma/schema", "prisma:validate": "prisma validate --schema=./prisma/schema", "prisma:format": "prisma format --schema=./prisma/schema", - "prisma:studio": "prisma db push --schema=./prisma/schema", + "prisma:push": "prisma db push --schema=./prisma/schema", + "prisma:studio": "prisma studio --schema=./prisma/schema", + "prisma:sync": "pnpm prisma:push && pnpm prisma:generate", "prisma:health": "tsx scripts/prisma-health-check.ts", - "prisma:test": "tsx scripts/prisma-crud-test.ts", + "prisma:test": "tsx scripts/check-replica-set.ts && tsx scripts/prisma-crud-test.ts", + "prisma:parity": "tsx scripts/check-replica-set.ts && tsx scripts/prisma-mongoose-parity.ts", "postinstall": "prisma generate --schema=./prisma/schema" }, "prisma": { diff --git a/quotevote-backend/prisma/schema/activity.prisma b/quotevote-backend/prisma/schema/activity.prisma index ab2e556d..8f2e8f1b 100644 --- a/quotevote-backend/prisma/schema/activity.prisma +++ b/quotevote-backend/prisma/schema/activity.prisma @@ -10,6 +10,8 @@ model Activity { commentId String? @db.ObjectId quoteId String? @db.ObjectId created DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations user User @relation(fields: [userId], references: [id]) diff --git a/quotevote-backend/prisma/schema/content.prisma b/quotevote-backend/prisma/schema/content.prisma index ab85f39b..b78b55d6 100644 --- a/quotevote-backend/prisma/schema/content.prisma +++ b/quotevote-backend/prisma/schema/content.prisma @@ -1,10 +1,12 @@ // Content models: Content, Creator, Domain, Collection model Domain { - id String @id @default(auto()) @map("_id") @db.ObjectId - key String @unique - name String? - created DateTime @default(now()) + id String @id @default(auto()) @map("_id") @db.ObjectId + key String @unique + name String? + created DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations contents Content[] @@ -13,11 +15,13 @@ model Domain { } model Creator { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String - avatar String? - bio String? - created DateTime @default(now()) + id String @id @default(auto()) @map("_id") @db.ObjectId + name String + avatar String? + bio String? + created DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations contents Content[] @@ -27,16 +31,14 @@ model Creator { } model Content { - id String @id @default(auto()) @map("_id") @db.ObjectId - title String - text String - creatorId String @db.ObjectId - coCreatorIds String[] @db.ObjectId - domainId String? @db.ObjectId - url String? - media Json? @default("[]") - score Int @default(0) - created DateTime @default(now()) + id String @id @default(auto()) @map("_id") @db.ObjectId + title String + creatorId String @db.ObjectId + domainId String? @db.ObjectId + url String? + created DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations creator Creator @relation(fields: [creatorId], references: [id]) @@ -49,14 +51,17 @@ model Content { model Collection { id String @id @default(auto()) @map("_id") @db.ObjectId - creatorId String @map("creatorId") @db.ObjectId - title String + userId String @db.ObjectId + name String description String? - contentIds String[] @db.ObjectId - options Json? + postIds String[] @db.ObjectId created DateTime @default(now()) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@index([creatorId]) + // Relations + user User @relation(fields: [userId], references: [id]) + + @@index([userId]) @@map("collections") } diff --git a/quotevote-backend/prisma/schema/enums.prisma b/quotevote-backend/prisma/schema/enums.prisma index 5a9ada5c..323b96a5 100644 --- a/quotevote-backend/prisma/schema/enums.prisma +++ b/quotevote-backend/prisma/schema/enums.prisma @@ -8,13 +8,14 @@ enum AccountStatus { } enum VoteType { - upvote - downvote + up + down } enum RosterStatus { pending accepted + declined blocked } @@ -32,6 +33,8 @@ enum NotificationType { MESSAGE MENTION SYSTEM + UPVOTED + DOWNVOTED } enum ActivityEventType { @@ -47,7 +50,7 @@ enum ActivityEventType { enum GroupPrivacy { public private - invite_only + restricted } enum InviteStatus { @@ -63,3 +66,18 @@ enum ReportStatus { resolved dismissed } + +enum ReportSeverity { + low + medium + high + critical +} + +enum ReportReason { + spam + harassment + inappropriate_content + fake_account + other +} diff --git a/quotevote-backend/prisma/schema/group.prisma b/quotevote-backend/prisma/schema/group.prisma index 85e2453d..58751b62 100644 --- a/quotevote-backend/prisma/schema/group.prisma +++ b/quotevote-backend/prisma/schema/group.prisma @@ -10,6 +10,7 @@ model Group { url String? description String? created DateTime @default(now()) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations diff --git a/quotevote-backend/prisma/schema/messaging.prisma b/quotevote-backend/prisma/schema/messaging.prisma index ed46f542..fa7e831c 100644 --- a/quotevote-backend/prisma/schema/messaging.prisma +++ b/quotevote-backend/prisma/schema/messaging.prisma @@ -1,6 +1,6 @@ -// Messaging models: PostMessage, DirectMessage, MessageRoom, Notification +// Messaging models: Message, DirectMessage, MessageRoom, Notification -// Composite types for PostMessage read receipts and delivery tracking +// Composite types for Message read receipts and delivery tracking type ReadByDetail { userId String @db.ObjectId readAt DateTime @@ -11,7 +11,7 @@ type DeliveredToDetail { deliveredAt DateTime } -model PostMessage { +model Message { id String @id @default(auto()) @map("_id") @db.ObjectId messageRoomId String @db.ObjectId userId String @db.ObjectId @@ -25,15 +25,17 @@ model PostMessage { deliveredTo DeliveredToDetail[] deleted Boolean @default(false) created DateTime @default(now()) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations - user User @relation("PostMessages", fields: [userId], references: [id]) + user User @relation("Messages", fields: [userId], references: [id]) messageRoom MessageRoom @relation(fields: [messageRoomId], references: [id]) reactions Reaction[] @@index([messageRoomId]) @@index([userId]) + @@index([messageRoomId, created]) @@index([created]) @@map("messages") } @@ -54,23 +56,24 @@ model DirectMessage { } model MessageRoom { - id String @id @default(auto()) @map("_id") @db.ObjectId - userIds String[] @db.ObjectId - postId String? @db.ObjectId - messageType MessageType @default(USER) - isDirect Boolean @default(false) + id String @id @default(auto()) @map("_id") @db.ObjectId + userIds String[] @map("users") @db.ObjectId + postId String? @db.ObjectId + messageType MessageType @default(USER) + isDirect Boolean @default(false) title String? avatar String? lastMessageTime DateTime? lastActivity DateTime? - lastSeenMessages Json? @default("{}") - unreadMessages Int @default(0) - created DateTime @default(now()) - updatedAt DateTime @updatedAt + lastSeenMessages Json? @default("{}") + unreadMessages Int @default(0) + created DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations - post Post? @relation(fields: [postId], references: [id]) - postMessages PostMessage[] + post Post? @relation(fields: [postId], references: [id]) + messages Message[] typingIndicators Typing[] @@index([postId]) @@ -84,10 +87,11 @@ model Notification { userId String @db.ObjectId userIdBy String @db.ObjectId label String - status String @default("unread") + status String @default("new") notificationType NotificationType postId String? @db.ObjectId created DateTime @default(now()) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations diff --git a/quotevote-backend/prisma/schema/post.prisma b/quotevote-backend/prisma/schema/post.prisma index 3894d662..c2d91f1b 100644 --- a/quotevote-backend/prisma/schema/post.prisma +++ b/quotevote-backend/prisma/schema/post.prisma @@ -12,19 +12,20 @@ model Post { downvotes Int @default(0) reported Int @default(0) approved Int? - votedBy Json? @default("[]") + votedBy String[] dayPoints Int @default(0) pointTimestamp DateTime @default(now()) - approvedBy String[] @db.ObjectId - rejectedBy String[] @db.ObjectId - reportedBy String[] @db.ObjectId - bookmarkedBy String[] @db.ObjectId - enableVoting Boolean @default(false) + approvedBy String[] + rejectedBy String[] + reportedBy String[] + bookmarkedBy String[] + enableVoting Boolean @default(false) @map("enable_voting") messageRoomId String? urlId String? featuredSlot Int? deleted Boolean @default(false) created DateTime @default(now()) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations @@ -64,6 +65,7 @@ model Comment { reaction String? deleted Boolean @default(false) created DateTime @default(now()) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations @@ -79,24 +81,21 @@ model Comment { model Quote { id String @id @default(auto()) @map("_id") @db.ObjectId - quoterId String @map("quoter") @db.ObjectId - quotedId String @map("quoted") @db.ObjectId + userId String @db.ObjectId postId String @db.ObjectId quote String startWordIndex Int? endWordIndex Int? - deleted Boolean @default(false) created DateTime @default(now()) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations - quoter User @relation("QuoterUser", fields: [quoterId], references: [id]) - quoted User @relation("QuotedUser", fields: [quotedId], references: [id]) + user User @relation(fields: [userId], references: [id]) post Post @relation(fields: [postId], references: [id]) activities Activity[] @relation("QuoteActivity") - @@index([quoterId]) - @@index([quotedId]) + @@index([userId]) @@index([postId]) @@index([created]) @@map("quotes") @@ -109,10 +108,11 @@ model Vote { type VoteType startWordIndex Int? endWordIndex Int? - tags String + tags String[] content String? deleted Boolean @default(false) created DateTime @default(now()) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations @@ -127,35 +127,47 @@ model Vote { } model VoteLog { - id String @id @default(auto()) @map("_id") @db.ObjectId - postId String @db.ObjectId - userId String @db.ObjectId - type VoteType - created DateTime @default(now()) + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + voteId String @db.ObjectId + postId String @db.ObjectId + title String? + author String? + description String + action String? + type VoteType + tokens Int + created DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations user User @relation(fields: [userId], references: [id]) post Post @relation(fields: [postId], references: [id]) - @@index([postId]) @@index([userId]) + @@index([voteId]) + @@index([postId]) @@map("votelogs") } model Reaction { - id String @id @default(auto()) @map("_id") @db.ObjectId - userId String @db.ObjectId - actionId String? @db.ObjectId - postMessageId String? @db.ObjectId - emoji String - created DateTime @default(now()) + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + actionId String? @db.ObjectId + messageId String? @db.ObjectId + emoji String + created DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations - user User @relation(fields: [userId], references: [id]) - postMessage PostMessage? @relation(fields: [postMessageId], references: [id]) + user User @relation(fields: [userId], references: [id]) + message Message? @relation(fields: [messageId], references: [id]) @@index([userId]) @@index([actionId]) - @@index([postMessageId]) + @@index([messageId]) + @@index([userId, messageId]) @@map("reactions") } diff --git a/quotevote-backend/prisma/schema/realtime.prisma b/quotevote-backend/prisma/schema/realtime.prisma index cc39f44f..ce1d16bd 100644 --- a/quotevote-backend/prisma/schema/realtime.prisma +++ b/quotevote-backend/prisma/schema/realtime.prisma @@ -13,13 +13,16 @@ model Presence { userId String @unique @db.ObjectId status PresenceStatus @default(offline) statusMessage String? - lastHeartbeat DateTime? - lastSeen DateTime? + lastHeartbeat DateTime? @default(now()) + lastSeen DateTime? @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations user User @relation(fields: [userId], references: [id]) @@index([status]) + @@index([lastHeartbeat]) @@map("presences") } @@ -27,8 +30,11 @@ model Typing { id String @id @default(auto()) @map("_id") @db.ObjectId messageRoomId String @db.ObjectId userId String @db.ObjectId - isTyping Boolean @default(false) + isTyping Boolean @default(true) timestamp DateTime @default(now()) + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations messageRoom MessageRoom @relation(fields: [messageRoomId], references: [id]) @@ -36,5 +42,6 @@ model Typing { @@unique([messageRoomId, userId]) @@index([messageRoomId]) + @@index([expiresAt]) @@map("typings") } diff --git a/quotevote-backend/prisma/schema/social.prisma b/quotevote-backend/prisma/schema/social.prisma index ea46e48f..1d6ea5af 100644 --- a/quotevote-backend/prisma/schema/social.prisma +++ b/quotevote-backend/prisma/schema/social.prisma @@ -8,6 +8,8 @@ model Roster { initiatedBy String? @db.ObjectId created DateTime @default(now()) updated DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations user User @relation("RosterUser", fields: [userId], references: [id]) @@ -23,30 +25,35 @@ model Roster { model UserInvite { id String @id @default(auto()) @map("_id") @db.ObjectId email String - invitedById String? @db.ObjectId - inviteeId String? @db.ObjectId - code String? @unique + invitedById String? @map("invitedBy") @db.ObjectId + code String? status InviteStatus @default(pending) created DateTime @default(now()) expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations invitedBy User? @relation("InviteSender", fields: [invitedById], references: [id]) - invitee User? @relation("InviteRecipient", fields: [inviteeId], references: [id]) @@index([email]) - @@index([invitedById]) + @@index([code]) @@index([status]) @@map("userinvites") } model UserReport { - id String @id @default(auto()) @map("_id") @db.ObjectId - reportedUserId String @db.ObjectId - reporterId String @db.ObjectId - reason String - status ReportStatus @default(pending) - created DateTime @default(now()) + id String @id @default(auto()) @map("_id") @db.ObjectId + reportedUserId String @db.ObjectId + reporterId String @db.ObjectId + reason ReportReason + description String? + status ReportStatus @default(pending) + severity ReportSeverity @default(medium) + adminNotes String? + created DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations reportedUser User @relation("ReportedUser", fields: [reportedUserId], references: [id]) @@ -63,16 +70,29 @@ model BotReport { userId String @db.ObjectId reporterId String @db.ObjectId created DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations reportedUser User @relation("BotReportedUser", fields: [userId], references: [id]) reporter User @relation("BotReporter", fields: [reporterId], references: [id]) + @@unique([reporterId, userId]) @@index([userId]) @@index([reporterId]) + @@index([createdAt(sort: Desc)]) @@map("botreports") } +// Composite type for embedded reputation on User documents +type EmbeddedReputation { + overallScore Float? @default(0) + inviteNetworkScore Float? @default(0) + conductScore Float? @default(0) + activityScore Float? @default(0) + metrics ReputationMetrics? +} + // Composite type for reputation metrics type ReputationMetrics { totalInvitesSent Int @default(0) @@ -96,10 +116,13 @@ model UserReputation { activityScore Float @default(0) metrics ReputationMetrics lastCalculated DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations user User @relation(fields: [userId], references: [id]) - @@index([overallScore]) + @@index([overallScore(sort: Desc)]) + @@index([lastCalculated(sort: Asc)]) @@map("userreputations") } diff --git a/quotevote-backend/prisma/schema/user.prisma b/quotevote-backend/prisma/schema/user.prisma index 8287d5e2..5021e3b5 100644 --- a/quotevote-backend/prisma/schema/user.prisma +++ b/quotevote-backend/prisma/schema/user.prisma @@ -1,5 +1,5 @@ // User and authentication related models -//User model - Core user entity +// User model - Core user entity model User { id String @id @default(auto()) @map("_id") @db.ObjectId @@ -7,7 +7,6 @@ model User { username String @unique name String? password String? - hashPassword String? @map("hash_password") avatar Json? bio String? location String? @@ -23,27 +22,28 @@ model User { contributorBadge Boolean @default(false) accountStatus AccountStatus @default(active) emailVerified Boolean @default(false) - isAdmin Boolean @default(false) + isAdmin Boolean @default(false) @map("admin") isModerator Boolean @default(false) botReports Int @default(0) lastBotReportDate DateTime? upvotes Int @default(0) downvotes Int @default(0) - followingIds String[] @db.ObjectId - followerIds String[] @db.ObjectId + followingIds String[] @map("_followingId") @db.ObjectId + followerIds String[] @map("_followersId") @db.ObjectId blockedUserIds String[] @db.ObjectId settings Json? lastLogin DateTime? joined DateTime @default(now()) - updated DateTime @updatedAt + reputation EmbeddedReputation? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - //Relations + // Relations posts Post[] comments Comment[] votes Vote[] - quotesAsQuoter Quote[] @relation("QuoterUser") - quotesAsQuoted Quote[] @relation("QuotedUser") - postMessages PostMessage[] @relation("PostMessages") + quotes Quote[] + messages Message[] @relation("Messages") directMessages DirectMessage[] @relation("DirectMessages") notifications Notification[] @relation("NotificationRecipient") notificationsFrom Notification[] @relation("NotificationSender") @@ -55,7 +55,6 @@ model User { presence Presence? typingIndicators Typing[] inviteSent UserInvite[] @relation("InviteSender") - inviteReceived UserInvite[] @relation("InviteRecipient") userReportsMade UserReport[] @relation("Reporter") userReportsReceived UserReport[] @relation("ReportedUser") botReportsMade BotReport[] @relation("BotReporter") @@ -63,6 +62,7 @@ model User { createdGroups Group[] @relation("GroupCreator") memberOfGroups String[] @db.ObjectId voteLog VoteLog[] + collections Collection[] @@index([accountStatus]) @@index([botReports, lastBotReportDate]) diff --git a/quotevote-backend/scripts/check-replica-set.ts b/quotevote-backend/scripts/check-replica-set.ts new file mode 100644 index 00000000..3bbb368b --- /dev/null +++ b/quotevote-backend/scripts/check-replica-set.ts @@ -0,0 +1,97 @@ +/// +/** + * Replica Set Preflight Check + * + * Prisma's MongoDB connector uses transactions internally for create/update/delete. + * MongoDB only supports transactions on replica sets. This script detects whether + * the configured DATABASE_URL points at a replica set and fails fast with a + * friendly, actionable message if not — so we never surface the raw P2031 error + * from the middle of a test run. + * + * Exits 0 on success (replica set detected). + * Exits 1 with an error message otherwise. + * + * Usage: tsx scripts/check-replica-set.ts + * (Wired as the preflight step before pnpm test:prisma / prisma:test / prisma:parity.) + */ + +import { PrismaClient } from '@prisma/client'; +import { config } from 'dotenv'; + +config(); + +const REPLICA_SET_HINT = ` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ⚠️ MongoDB is not running as a replica set. │ +│ │ +│ Prisma requires a replica set because it uses transactions for │ +│ create/update/delete operations. A standalone mongod cannot serve these. │ +│ │ +│ Fix — pick one: │ +│ │ +│ 1. Docker (fastest): │ +│ docker run -d --name mongo-rs -p 27017:27017 \\ │ +│ mongo:7 --replSet rs0 --bind_ip_all │ +│ docker exec -it mongo-rs mongosh --eval "rs.initiate()" │ +│ │ +│ 2. Local mongod: │ +│ Start with: mongod --replSet rs0 --dbpath │ +│ Then in mongosh (one time): rs.initiate() │ +│ │ +│ 3. MongoDB Atlas: │ +│ Atlas clusters are already replica sets — just paste the connection │ +│ string into DATABASE_URL. │ +│ │ +│ Then ensure DATABASE_URL in .env includes the replica set, e.g.: │ +│ DATABASE_URL="mongodb://localhost:27017/quotevote?replicaSet=rs0 │ +│ &directConnection=true" │ +│ │ +│ See docs/prisma-migration-strategy.md for the full explanation. │ +└─────────────────────────────────────────────────────────────────────────────┘ +`; + +function isReplicaSetError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /replica\s*set/i.test(msg) || /P2031|P2010/.test(msg); +} + +async function main(): Promise { + if (!process.env.DATABASE_URL) { + console.error('❌ DATABASE_URL is not set in .env'); + process.exit(1); + } + + const prisma = new PrismaClient(); + + try { + await prisma.$connect(); + + // Probe: attempt a transactional write. This fails with P2031 on a + // standalone mongod but succeeds on any replica set. + const stamp = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const probeUser = await prisma.user.create({ + data: { + email: `replica-probe-${stamp}@internal.test`, + username: `replica_probe_${stamp}`, + accountStatus: 'active', + }, + }); + // Clean up the probe record. + await prisma.user.delete({ where: { id: probeUser.id } }); + + console.log('✅ MongoDB replica set detected — Prisma integration OK'); + process.exit(0); + } catch (err) { + if (isReplicaSetError(err)) { + console.error(REPLICA_SET_HINT); + process.exit(1); + } + console.error('❌ Preflight check failed for a different reason:'); + console.error(err); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/quotevote-backend/scripts/prisma-crud-test.ts b/quotevote-backend/scripts/prisma-crud-test.ts index 5922df5b..dfcac053 100644 --- a/quotevote-backend/scripts/prisma-crud-test.ts +++ b/quotevote-backend/scripts/prisma-crud-test.ts @@ -1,7 +1,18 @@ /// /** - * Prisma CRUD Test Script - * Tests Create, Read, Update, Delete operations with Prisma Client + * Prisma CRUD Test Script — ALL MODELS + * + * Exercises Create, Read, Update, Delete and relationship traversal across + * every model defined in `prisma/schema/*.prisma` (24 models total). Also + * validates that deep nested `include` queries traverse multiple relationship + * levels correctly (user → posts → comments, room → messages → reactions, etc.). + * + * Models covered (24): + * User, Group, Post, Comment, Quote, Vote, VoteLog, Reaction, + * Message, DirectMessage, MessageRoom, Notification, + * Activity, Roster, Presence, Typing, + * UserInvite, UserReport, BotReport, UserReputation, + * Domain, Creator, Content, Collection * * Usage: pnpm prisma:test */ @@ -10,168 +21,738 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -async function main() { - console.log('🧪 Starting Prisma CRUD Tests...\n'); +// Track created docs for LIFO cleanup +interface Created { + model: string; + id: string; +} +const created: Created[] = []; + +function track(model: string, id: string): void { + created.unshift({ model, id }); +} + +type PrismaDelegate = { + delete: (args: { where: { id: string } }) => Promise; +}; + +function delegateFor(model: string): PrismaDelegate | null { + const map: Record = { + user: prisma.user, + group: prisma.group, + post: prisma.post, + comment: prisma.comment, + quote: prisma.quote, + vote: prisma.vote, + voteLog: prisma.voteLog, + reaction: prisma.reaction, + message: prisma.message, + directMessage: prisma.directMessage, + messageRoom: prisma.messageRoom, + notification: prisma.notification, + activity: prisma.activity, + roster: prisma.roster, + presence: prisma.presence, + typing: prisma.typing, + userInvite: prisma.userInvite, + userReport: prisma.userReport, + botReport: prisma.botReport, + userReputation: prisma.userReputation, + domain: prisma.domain, + creator: prisma.creator, + content: prisma.content, + collection: prisma.collection, + }; + return map[model] ?? null; +} + +let testsPassed = 0; +let testsFailed = 0; +async function step(name: string, fn: () => Promise): Promise { try { - // ============================================ - // Test 1: Connection - // ============================================ - console.log('📡 Test 1: Database Connection'); - await prisma.$connect(); - console.log(' ✅ Connected to MongoDB successfully!\n'); - - // ============================================ - // Test 2: CREATE - Create a test user - // ============================================ - console.log('📝 Test 2: CREATE - Creating a test user'); - const testEmail = `test-${Date.now()}@prisma-test.com`; - const testUsername = `testuser_${Date.now()}`; - - const newUser = await prisma.user.create({ + await fn(); + console.log(` ✅ ${name}`); + testsPassed += 1; + } catch (err) { + console.log(` ❌ ${name}`); + console.log(` ${(err as Error).message}`); + testsFailed += 1; + throw err; + } +} + +function uniq(): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +// --------------------------------------------------------------------------- +// Per-model CRUD round-trips +// --------------------------------------------------------------------------- + +async function testUser(): Promise { + console.log('\n👤 User'); + const u = uniq(); + let userId = ''; + + await step('CREATE user', async () => { + const user = await prisma.user.create({ data: { - email: testEmail, - username: testUsername, - name: 'Prisma Test User', + email: `crud-${u}@prisma-test.com`, + username: `crud_${u}`, + name: 'CRUD User', accountStatus: 'active', - emailVerified: false, - isAdmin: false, - isModerator: false, - upvotes: 0, - downvotes: 0, - botReports: 0, }, }); - console.log(` ✅ Created user: ${newUser.username} (ID: ${newUser.id})\n`); + track('user', user.id); + userId = user.id; + }); - // ============================================ - // Test 3: READ - Query the created user - // ============================================ - console.log('🔍 Test 3: READ - Querying the user'); - const foundUser = await prisma.user.findUnique({ - where: { id: newUser.id }, + await step('READ user', async () => { + const found = await prisma.user.findUnique({ where: { id: userId } }); + if (!found) throw new Error('user not found'); + }); + + await step('UPDATE user', async () => { + const updated = await prisma.user.update({ + where: { id: userId }, + data: { upvotes: 42, name: 'Updated' }, }); + if (updated.upvotes !== 42) throw new Error('update failed'); + }); - if (foundUser && foundUser.email === testEmail) { - console.log(` ✅ Found user: ${foundUser.email}\n`); - } else { - throw new Error('User not found or email mismatch'); - } + return userId; +} + +async function testGroup(ownerId: string): Promise { + console.log('\n👥 Group'); + let groupId = ''; + await step('CREATE group', async () => { + const group = await prisma.group.create({ + data: { creatorId: ownerId, title: `Group ${uniq()}`, privacy: 'public' }, + }); + track('group', group.id); + groupId = group.id; + }); + await step('UPDATE group', async () => { + await prisma.group.update({ + where: { id: groupId }, + data: { title: 'Renamed Group' }, + }); + }); + return groupId; +} - // ============================================ - // Test 4: UPDATE - Update the user - // ============================================ - console.log('✏️ Test 4: UPDATE - Updating the user'); - const updatedUser = await prisma.user.update({ - where: { id: newUser.id }, +async function testPost(userId: string, groupId: string): Promise { + console.log('\n📝 Post'); + let postId = ''; + await step('CREATE post', async () => { + const post = await prisma.post.create({ data: { - name: 'Updated Prisma Test User', - emailVerified: true, - upvotes: 10, + userId, + groupId, + title: 'CRUD Post', + text: 'Body', + enableVoting: true, }, }); + track('post', post.id); + postId = post.id; + }); + await step('READ post → user relation', async () => { + const p = await prisma.post.findUnique({ + where: { id: postId }, + include: { user: true, group: true }, + }); + if (!p || p.user.id !== userId) throw new Error('relation broken'); + }); + await step('UPDATE post', async () => { + await prisma.post.update({ where: { id: postId }, data: { upvotes: 5 } }); + }); + return postId; +} - if (updatedUser.name === 'Updated Prisma Test User' && updatedUser.emailVerified === true) { - console.log(` ✅ Updated user name to: ${updatedUser.name}\n`); - } else { - throw new Error('Update failed'); - } +async function testComment(userId: string, postId: string): Promise { + console.log('\n💬 Comment'); + let id = ''; + await step('CREATE comment', async () => { + const c = await prisma.comment.create({ + data: { userId, postId, content: 'CRUD comment' }, + }); + track('comment', c.id); + id = c.id; + }); + await step('UPDATE comment', async () => { + await prisma.comment.update({ where: { id }, data: { content: 'Edited' } }); + }); + return id; +} + +async function testQuote(userId: string, postId: string): Promise { + console.log('\n💭 Quote'); + let id = ''; + await step('CREATE quote', async () => { + const q = await prisma.quote.create({ + data: { userId, postId, quote: 'CRUD quote' }, + }); + track('quote', q.id); + id = q.id; + }); + await step('READ quote → post relation', async () => { + const q = await prisma.quote.findUnique({ where: { id }, include: { post: true } }); + if (!q || q.post.id !== postId) throw new Error('relation broken'); + }); + return id; +} - // ============================================ - // Test 5: CREATE related - Create a post for the user - // ============================================ - console.log('📝 Test 5: CREATE - Creating a post for the user'); - - // First create a group for the post (required relation) - const testGroup = await prisma.group.create({ +async function testVote(userId: string, postId: string): Promise { + console.log('\n👍 Vote'); + let id = ''; + await step('CREATE vote (up)', async () => { + const v = await prisma.vote.create({ + data: { userId, postId, type: 'up', tags: ['good'] }, + }); + track('vote', v.id); + id = v.id; + }); + await step('UPDATE vote', async () => { + await prisma.vote.update({ where: { id }, data: { tags: ['good', 'insightful'] } }); + }); + return id; +} + +async function testVoteLog(userId: string, postId: string, voteId: string): Promise { + console.log('\n📜 VoteLog'); + let id = ''; + await step('CREATE voteLog', async () => { + const vl = await prisma.voteLog.create({ data: { - creatorId: newUser.id, - title: 'Test Group', - privacy: 'public', + userId, + voteId, + postId, + description: 'User cast upvote', + type: 'up', + tokens: 1, }, }); - - const newPost = await prisma.post.create({ + track('voteLog', vl.id); + id = vl.id; + }); + return id; +} + +async function testMessageRoom(userId: string): Promise { + console.log('\n💬 MessageRoom'); + let id = ''; + await step('CREATE messageRoom', async () => { + const r = await prisma.messageRoom.create({ + data: { userIds: [userId], messageType: 'USER', isDirect: true }, + }); + track('messageRoom', r.id); + id = r.id; + }); + await step('UPDATE messageRoom', async () => { + await prisma.messageRoom.update({ where: { id }, data: { unreadMessages: 3 } }); + }); + return id; +} + +async function testMessage(userId: string, roomId: string): Promise { + console.log('\n📨 Message'); + let id = ''; + await step('CREATE message', async () => { + const m = await prisma.message.create({ + data: { messageRoomId: roomId, userId, text: 'Hello!' }, + }); + track('message', m.id); + id = m.id; + }); + await step('READ message → room relation', async () => { + const m = await prisma.message.findUnique({ + where: { id }, + include: { messageRoom: true }, + }); + if (!m || m.messageRoom.id !== roomId) throw new Error('relation broken'); + }); + return id; +} + +async function testDirectMessage(userId: string): Promise { + console.log('\n✉️ DirectMessage'); + let id = ''; + await step('CREATE directMessage', async () => { + const dm = await prisma.directMessage.create({ + data: { userId, text: 'Direct hello', title: 'DM' }, + }); + track('directMessage', dm.id); + id = dm.id; + }); + return id; +} + +async function testReaction(userId: string, messageId: string): Promise { + console.log('\n😀 Reaction'); + let id = ''; + await step('CREATE reaction', async () => { + const r = await prisma.reaction.create({ + data: { userId, messageId, emoji: '🔥' }, + }); + track('reaction', r.id); + id = r.id; + }); + return id; +} + +async function testNotification(recipientId: string, senderId: string, postId: string): Promise { + console.log('\n🔔 Notification'); + let id = ''; + await step('CREATE notification', async () => { + const n = await prisma.notification.create({ data: { - user: { connect: { id: newUser.id } }, - group: { connect: { id: testGroup.id } }, - title: 'Test Post from Prisma', - text: 'This is a test post created by the Prisma CRUD test script.', - upvotes: 0, - downvotes: 0, - enableVoting: false, - deleted: false, + userId: recipientId, + userIdBy: senderId, + label: 'upvoted your post', + notificationType: 'UPVOTED', + postId, }, }); - console.log(` ✅ Created post: "${newPost.title}" (ID: ${newPost.id})\n`); + track('notification', n.id); + id = n.id; + }); + await step('UPDATE notification status', async () => { + await prisma.notification.update({ where: { id }, data: { status: 'read' } }); + }); + return id; +} + +async function testActivity(userId: string, postId: string): Promise { + console.log('\n📈 Activity'); + let id = ''; + await step('CREATE activity', async () => { + const a = await prisma.activity.create({ + data: { userId, postId, activityType: 'POSTED' }, + }); + track('activity', a.id); + id = a.id; + }); + return id; +} - // ============================================ - // Test 6: READ with relations - Query user with posts - // ============================================ - console.log('🔍 Test 6: READ - Querying user with posts (relations)'); - const userWithPosts = await prisma.user.findUnique({ - where: { id: newUser.id }, - include: { posts: true }, +async function testRoster(user1: string, user2: string): Promise { + console.log('\n🤝 Roster'); + let id = ''; + await step('CREATE roster', async () => { + const r = await prisma.roster.create({ + data: { userId: user1, buddyId: user2, status: 'pending' }, }); + track('roster', r.id); + id = r.id; + }); + await step('UPDATE roster status', async () => { + await prisma.roster.update({ where: { id }, data: { status: 'accepted' } }); + }); + return id; +} + +async function testPresence(userId: string): Promise { + console.log('\n🟢 Presence'); + let id = ''; + await step('CREATE presence', async () => { + const p = await prisma.presence.create({ + data: { userId, status: 'online' }, + }); + track('presence', p.id); + id = p.id; + }); + await step('UPDATE presence', async () => { + await prisma.presence.update({ where: { id }, data: { status: 'away' } }); + }); + return id; +} - if (userWithPosts && userWithPosts.posts.length > 0) { - console.log(` ✅ User has ${userWithPosts.posts.length} post(s)\n`); - } else { - throw new Error('Posts relation not working'); +async function testTyping(userId: string, roomId: string): Promise { + console.log('\n⌨️ Typing'); + let id = ''; + await step('CREATE typing', async () => { + const t = await prisma.typing.create({ + data: { messageRoomId: roomId, userId, isTyping: true }, + }); + track('typing', t.id); + id = t.id; + }); + return id; +} + +async function testUserInvite(inviterId: string): Promise { + console.log('\n📬 UserInvite'); + let id = ''; + await step('CREATE userInvite', async () => { + const inv = await prisma.userInvite.create({ + data: { + email: `invitee-${uniq()}@test.com`, + invitedById: inviterId, + code: `CODE-${uniq()}`, + status: 'pending', + }, + }); + track('userInvite', inv.id); + id = inv.id; + }); + await step('UPDATE userInvite status', async () => { + await prisma.userInvite.update({ where: { id }, data: { status: 'accepted' } }); + }); + return id; +} + +async function testUserReport(reporter: string, reported: string): Promise { + console.log('\n🚩 UserReport'); + let id = ''; + await step('CREATE userReport', async () => { + const r = await prisma.userReport.create({ + data: { + reporterId: reporter, + reportedUserId: reported, + reason: 'spam', + description: 'Test report', + }, + }); + track('userReport', r.id); + id = r.id; + }); + await step('UPDATE userReport status', async () => { + await prisma.userReport.update({ where: { id }, data: { status: 'reviewed' } }); + }); + return id; +} + +async function testBotReport(reporter: string, reported: string): Promise { + console.log('\n🤖 BotReport'); + let id = ''; + await step('CREATE botReport', async () => { + const r = await prisma.botReport.create({ + data: { reporterId: reporter, userId: reported }, + }); + track('botReport', r.id); + id = r.id; + }); + return id; +} + +async function testUserReputation(userId: string): Promise { + console.log('\n⭐ UserReputation'); + let id = ''; + await step('CREATE userReputation', async () => { + const r = await prisma.userReputation.create({ + data: { + userId, + overallScore: 75.5, + inviteNetworkScore: 10, + conductScore: 30, + activityScore: 35.5, + metrics: { + totalInvitesSent: 5, + totalInvitesAccepted: 3, + totalInvitesDeclined: 1, + averageInviteeReputation: 50, + totalReportsReceived: 0, + totalReportsResolved: 0, + totalUpvotes: 20, + totalDownvotes: 2, + totalPosts: 4, + totalComments: 15, + }, + }, + }); + track('userReputation', r.id); + id = r.id; + }); + await step('UPDATE userReputation score', async () => { + await prisma.userReputation.update({ where: { id }, data: { overallScore: 80 } }); + }); + return id; +} + +async function testDomain(): Promise { + console.log('\n🌐 Domain'); + let id = ''; + await step('CREATE domain', async () => { + const d = await prisma.domain.create({ + data: { key: `domain_${uniq()}`, name: 'Test Domain' }, + }); + track('domain', d.id); + id = d.id; + }); + return id; +} + +async function testCreator(): Promise { + console.log('\n🎨 Creator'); + let id = ''; + await step('CREATE creator', async () => { + const c = await prisma.creator.create({ + data: { name: `Creator ${uniq()}`, bio: 'A creator' }, + }); + track('creator', c.id); + id = c.id; + }); + return id; +} + +async function testContent(creatorId: string, domainId: string): Promise { + console.log('\n📄 Content'); + let id = ''; + await step('CREATE content', async () => { + const c = await prisma.content.create({ + data: { + title: 'CRUD content', + creatorId, + domainId, + url: 'https://example.com/article', + }, + }); + track('content', c.id); + id = c.id; + }); + await step('READ content → creator + domain relations', async () => { + const c = await prisma.content.findUnique({ + where: { id }, + include: { creator: true, domain: true }, + }); + if (!c || c.creator.id !== creatorId || c.domain?.id !== domainId) { + throw new Error('relations broken'); } + }); + return id; +} - // ============================================ - // Test 7: COUNT - Count all users - // ============================================ - console.log('📊 Test 7: COUNT - Counting users'); - const userCount = await prisma.user.count(); - console.log(` ✅ Total users in database: ${userCount}\n`); - - // ============================================ - // Test 8: DELETE - Clean up test data - // ============================================ - console.log('🗑️ Test 8: DELETE - Cleaning up test data'); - - // Delete the post first (due to relation) - await prisma.post.delete({ - where: { id: newPost.id }, - }); - console.log(' ✅ Deleted test post'); - - // Delete the group - await prisma.group.delete({ - where: { id: testGroup.id }, - }); - console.log(' ✅ Deleted test group'); - - // Delete the user - await prisma.user.delete({ - where: { id: newUser.id }, - }); - console.log(' ✅ Deleted test user\n'); - - // ============================================ - // Summary - // ============================================ - console.log('═══════════════════════════════════════════'); - console.log('🎉 ALL TESTS PASSED!'); - console.log('═══════════════════════════════════════════'); - console.log('✅ Connection: OK'); - console.log('✅ CREATE: OK'); - console.log('✅ READ: OK'); - console.log('✅ UPDATE: OK'); - console.log('✅ Relations: OK'); - console.log('✅ COUNT: OK'); - console.log('✅ DELETE: OK'); - console.log('═══════════════════════════════════════════\n'); - } catch (error) { - console.error('\n❌ TEST FAILED:'); - console.error(error); - process.exit(1); +async function testCollection(userId: string, postId: string): Promise { + console.log('\n📚 Collection'); + let id = ''; + await step('CREATE collection', async () => { + const c = await prisma.collection.create({ + data: { + userId, + name: `Collection ${uniq()}`, + postIds: [postId], + }, + }); + track('collection', c.id); + id = c.id; + }); + await step('UPDATE collection (append postId)', async () => { + await prisma.collection.update({ + where: { id }, + data: { description: 'Updated description' }, + }); + }); + return id; +} + +// --------------------------------------------------------------------------- +// Deep nested traversal across multiple levels +// --------------------------------------------------------------------------- + +async function testDeepTraversal(userId: string, postId: string, roomId: string): Promise { + console.log('\n🕸️ Deep Relationship Traversal'); + + await step('user → posts → comments + votes + quotes', async () => { + const u = await prisma.user.findUnique({ + where: { id: userId }, + include: { + posts: { + include: { comments: true, votes: true, quotes: true }, + }, + }, + }); + if (!u) throw new Error('user not found'); + const p = u.posts.find((x) => x.id === postId); + if (!p) throw new Error('post not found in user.posts'); + if (p.comments.length < 1) throw new Error('no comments traversed'); + if (p.votes.length < 1) throw new Error('no votes traversed'); + if (p.quotes.length < 1) throw new Error('no quotes traversed'); + }); + + await step('messageRoom → messages → reactions', async () => { + const r = await prisma.messageRoom.findUnique({ + where: { id: roomId }, + include: { + messages: { include: { reactions: true } }, + typingIndicators: true, + }, + }); + if (!r) throw new Error('room not found'); + if (r.messages.length < 1) throw new Error('no messages traversed'); + if (r.messages[0].reactions.length < 1) throw new Error('no reactions traversed'); + }); + + await step('user → activities + notifications + collections', async () => { + const u = await prisma.user.findUnique({ + where: { id: userId }, + include: { + activities: true, + notifications: true, + collections: true, + }, + }); + if (!u) throw new Error('user not found'); + if (u.activities.length < 1) throw new Error('no activities'); + if (u.collections.length < 1) throw new Error('no collections'); + }); + + await step('COUNT across all 24 models', async () => { + // Verify every model's delegate is callable — catches missing/broken models + await Promise.all([ + prisma.user.count(), + prisma.group.count(), + prisma.post.count(), + prisma.comment.count(), + prisma.quote.count(), + prisma.vote.count(), + prisma.voteLog.count(), + prisma.reaction.count(), + prisma.message.count(), + prisma.directMessage.count(), + prisma.messageRoom.count(), + prisma.notification.count(), + prisma.activity.count(), + prisma.roster.count(), + prisma.presence.count(), + prisma.typing.count(), + prisma.userInvite.count(), + prisma.userReport.count(), + prisma.botReport.count(), + prisma.userReputation.count(), + prisma.domain.count(), + prisma.creator.count(), + prisma.content.count(), + prisma.collection.count(), + ]); + }); +} + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +async function cleanup(): Promise { + console.log('\n🗑️ DELETE — Cleaning up all test data (LIFO)'); + for (const { model, id } of created) { + try { + const delegate = delegateFor(model); + if (delegate) await delegate.delete({ where: { id } }); + } catch { + // Already gone — ignore + } + } + console.log(` ✅ Deleted ${created.length} records across ${new Set(created.map((c) => c.model)).size} models`); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function isReplicaSetError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /replica set/i.test(msg) || /P2031/.test(msg); +} + +function printReplicaSetHint(): void { + console.error( + '\n⚠️ MongoDB is not running as a replica set.\n' + + ' Prisma requires a replica set because create/update/delete use transactions.\n' + + ' Fix (fastest): docker run -d --name mongo-rs -p 27017:27017 \\\n' + + ' mongo:7 --replSet rs0 --bind_ip_all\n' + + ' docker exec -it mongo-rs mongosh --eval "rs.initiate()"\n' + + ' Update DATABASE_URL to include ?replicaSet=rs0&directConnection=true\n' + + ' See docs/prisma-migration-strategy.md for all options.\n', + ); +} + +async function main(): Promise { + console.log('🧪 Prisma CRUD Tests — ALL 24 MODELS\n'); + console.log('═══════════════════════════════════════════'); + console.log('📡 Connection'); + await prisma.$connect(); + console.log(' ✅ Connected to MongoDB'); + + try { + // Core user + const primaryId = await testUser(); + + // Secondary user for relationship models + const secondary = await prisma.user.create({ + data: { + email: `crud-sec-${uniq()}@prisma-test.com`, + username: `crud_sec_${uniq()}`, + accountStatus: 'active', + }, + }); + track('user', secondary.id); + const secondaryId = secondary.id; + + // Group + Post + engagement + const groupId = await testGroup(primaryId); + const postId = await testPost(primaryId, groupId); + await testComment(primaryId, postId); + await testQuote(primaryId, postId); + const voteId = await testVote(primaryId, postId); + await testVoteLog(primaryId, postId, voteId); + + // Messaging + const roomId = await testMessageRoom(primaryId); + const msgId = await testMessage(primaryId, roomId); + await testDirectMessage(primaryId); + await testReaction(primaryId, msgId); + await testNotification(primaryId, secondaryId, postId); + + // Activity + Social + await testActivity(primaryId, postId); + await testRoster(primaryId, secondaryId); + await testPresence(primaryId); + await testTyping(primaryId, roomId); + + // Invitations & reports + await testUserInvite(primaryId); + await testUserReport(primaryId, secondaryId); + await testBotReport(primaryId, secondaryId); + await testUserReputation(secondaryId); + + // Content domain + const domainId = await testDomain(); + const creatorId = await testCreator(); + await testContent(creatorId, domainId); + await testCollection(primaryId, postId); + + // Deep traversal — verifies multi-level includes work + await testDeepTraversal(primaryId, postId, roomId); } finally { + await cleanup(); await prisma.$disconnect(); - console.log('🔌 Disconnected from MongoDB.'); + console.log('🔌 Disconnected from MongoDB'); + } + + console.log('\n═══════════════════════════════════════════'); + console.log(`📊 CRUD Test Results: ${testsPassed} passed, ${testsFailed} failed`); + console.log('═══════════════════════════════════════════'); + + if (testsFailed > 0) { + console.log('\n❌ Some tests failed'); + process.exit(1); } + console.log('\n🎉 ALL TESTS PASSED — all 24 models support CRUD + relationship traversal\n'); } -main(); +main().catch((err: Error) => { + if (isReplicaSetError(err)) { + printReplicaSetHint(); + cleanup() + .finally(() => prisma.$disconnect()) + .finally(() => process.exit(1)); + return; + } + console.error('\n💥 CRUD test crashed:'); + console.error(err); + cleanup() + .finally(() => prisma.$disconnect()) + .finally(() => process.exit(1)); +}); diff --git a/quotevote-backend/scripts/prisma-health-check.ts b/quotevote-backend/scripts/prisma-health-check.ts index d731e1f9..ac624d1a 100644 --- a/quotevote-backend/scripts/prisma-health-check.ts +++ b/quotevote-backend/scripts/prisma-health-check.ts @@ -34,7 +34,7 @@ async function main() { 'VoteLog', 'Quote', 'Reaction', - 'PostMessage', + 'Message', 'DirectMessage', 'MessageRoom', 'Notification', diff --git a/quotevote-backend/scripts/prisma-mongoose-parity.ts b/quotevote-backend/scripts/prisma-mongoose-parity.ts new file mode 100644 index 00000000..cc554ee0 --- /dev/null +++ b/quotevote-backend/scripts/prisma-mongoose-parity.ts @@ -0,0 +1,525 @@ +/// +/** + * Prisma ↔ Mongoose Parity Check + * + * Verifies that Prisma and Mongoose read/write the same MongoDB collections + * correctly, with field-name mappings (@map) surfacing as expected on both sides. + * + * Critical mapped fields verified: + * User: admin ↔ isAdmin + * _followingId ↔ followingIds + * _followersId ↔ followersIds + * _wallet ↔ wallet + * Post: enable_voting ↔ enableVoting + * MessageRoom: users ↔ userIds + * + * For each model, runs two round-trips: + * 1. Mongoose writes → Prisma reads (verify mapped fields surface on Prisma side) + * 2. Prisma writes → Mongoose reads (verify raw MongoDB field names are correct) + * + * Requires a live MongoDB instance with DATABASE_URL and MONGO_URI set in .env. + * Exits 0 on success, 1 on any mismatch. + * + * Usage: pnpm prisma:parity + */ + +import { PrismaClient } from '@prisma/client'; +import mongoose from 'mongoose'; +import { config } from 'dotenv'; + +import User from '../app/data/models/User'; +import Post from '../app/data/models/Post'; +import MessageRoom from '../app/data/models/MessageRoom'; +import Group from '../app/data/models/Group'; + +config(); + +const prisma = new PrismaClient(); + +interface ParityResult { + model: string; + direction: 'Mongoose → Prisma' | 'Prisma → Mongoose'; + field: string; + expected: unknown; + actual: unknown; + passed: boolean; +} + +const results: ParityResult[] = []; + +// Track created docs for cleanup (LIFO) +interface CreatedDoc { + kind: 'mongoose' | 'prisma'; + model: string; + id: string; +} + +const created: CreatedDoc[] = []; + +function record(result: ParityResult): void { + results.push(result); + const icon = result.passed ? '✅' : '❌'; + const summary = `${icon} [${result.model}] ${result.direction} — ${result.field}`; + if (result.passed) { + console.log(` ${summary}`); + } else { + console.log(` ${summary}`); + console.log(` expected: ${JSON.stringify(result.expected)}`); + console.log(` actual: ${JSON.stringify(result.actual)}`); + } +} + +function assertEqual( + model: string, + direction: ParityResult['direction'], + field: string, + expected: unknown, + actual: unknown, +): void { + const passed = JSON.stringify(expected) === JSON.stringify(actual); + record({ model, direction, field, expected, actual, passed }); +} + +// --------------------------------------------------------------------------- +// User parity +// --------------------------------------------------------------------------- + +async function checkUser(suffix: string): Promise { + console.log('\n👤 User model parity'); + + const followeeId1 = new mongoose.Types.ObjectId(); + const followeeId2 = new mongoose.Types.ObjectId(); + + // --- Mongoose → Prisma + const mongooseUser = await User.create({ + email: `mongoose-${suffix}@parity.test`, + username: `mongoose_${suffix}`, + password: 'not-a-real-password', + admin: true, + _followingId: [followeeId1, followeeId2], + _followersId: [followeeId1], + _wallet: 'wallet-abc-123', + upvotes: 7, + }); + created.push({ kind: 'mongoose', model: 'User', id: String(mongooseUser._id) }); + + const prismaView = await prisma.user.findUnique({ + where: { id: String(mongooseUser._id) }, + }); + + if (!prismaView) { + throw new Error(`Prisma could not find user ${mongooseUser._id} created via Mongoose`); + } + + assertEqual('User', 'Mongoose → Prisma', 'admin → isAdmin', true, prismaView.isAdmin); + assertEqual( + 'User', + 'Mongoose → Prisma', + '_followingId → followingIds', + [String(followeeId1), String(followeeId2)], + prismaView.followingIds, + ); + assertEqual( + 'User', + 'Mongoose → Prisma', + '_followersId → followerIds', + [String(followeeId1)], + prismaView.followerIds, + ); + assertEqual('User', 'Mongoose → Prisma', '_wallet → wallet', 'wallet-abc-123', prismaView.wallet); + assertEqual('User', 'Mongoose → Prisma', 'upvotes', 7, prismaView.upvotes); + + // --- Prisma → Mongoose + const prismaUser = await prisma.user.create({ + data: { + email: `prisma-${suffix}@parity.test`, + username: `prisma_${suffix}`, + password: 'not-a-real-password', + isAdmin: true, + followingIds: [String(followeeId1)], + followerIds: [String(followeeId2)], + wallet: 'wallet-xyz-789', + downvotes: 3, + }, + }); + created.push({ kind: 'prisma', model: 'user', id: prismaUser.id }); + + const mongooseView = await User.findById(prismaUser.id).lean(); + if (!mongooseView) { + throw new Error(`Mongoose could not find user ${prismaUser.id} created via Prisma`); + } + + // Mongoose .lean() returns plain object; its shape matches the raw MongoDB doc + const raw = mongooseView as unknown as { + admin?: boolean; + _followingId?: mongoose.Types.ObjectId[]; + _followersId?: mongoose.Types.ObjectId[]; + _wallet?: string; + downvotes?: number; + }; + + assertEqual('User', 'Prisma → Mongoose', 'isAdmin → admin', true, raw.admin); + assertEqual( + 'User', + 'Prisma → Mongoose', + 'followingIds → _followingId', + [String(followeeId1)], + (raw._followingId ?? []).map(String), + ); + assertEqual( + 'User', + 'Prisma → Mongoose', + 'followerIds → _followersId', + [String(followeeId2)], + (raw._followersId ?? []).map(String), + ); + assertEqual('User', 'Prisma → Mongoose', 'wallet → _wallet', 'wallet-xyz-789', raw._wallet); + assertEqual('User', 'Prisma → Mongoose', 'downvotes', 3, raw.downvotes); +} + +// --------------------------------------------------------------------------- +// Post parity (requires a User and Group) +// --------------------------------------------------------------------------- + +async function checkPost(suffix: string): Promise { + console.log('\n📝 Post model parity'); + + // Create prerequisite user and group via Mongoose + const user = await User.create({ + email: `post-owner-${suffix}@parity.test`, + username: `post_owner_${suffix}`, + password: 'not-a-real-password', + }); + created.push({ kind: 'mongoose', model: 'User', id: String(user._id) }); + + const group = await Group.create({ + creatorId: user._id, + title: `Parity group ${suffix}`, + privacy: 'public', + }); + created.push({ kind: 'mongoose', model: 'Group', id: String(group._id) }); + + // --- Mongoose → Prisma + const mongoosePost = await Post.create({ + userId: user._id, + groupId: group._id, + title: 'Mongoose post', + text: 'Body', + enable_voting: true, + }); + created.push({ kind: 'mongoose', model: 'Post', id: String(mongoosePost._id) }); + + const prismaPostView = await prisma.post.findUnique({ + where: { id: String(mongoosePost._id) }, + }); + if (!prismaPostView) { + throw new Error(`Prisma could not find post ${mongoosePost._id} created via Mongoose`); + } + + assertEqual( + 'Post', + 'Mongoose → Prisma', + 'enable_voting → enableVoting', + true, + prismaPostView.enableVoting, + ); + + // --- Prisma → Mongoose + const prismaPost = await prisma.post.create({ + data: { + userId: String(user._id), + groupId: String(group._id), + title: 'Prisma post', + text: 'Body', + enableVoting: true, + }, + }); + created.push({ kind: 'prisma', model: 'post', id: prismaPost.id }); + + const rawMongoosePost = (await Post.findById(prismaPost.id).lean()) as unknown as { + enable_voting?: boolean; + } | null; + if (!rawMongoosePost) { + throw new Error(`Mongoose could not find post ${prismaPost.id} created via Prisma`); + } + + assertEqual( + 'Post', + 'Prisma → Mongoose', + 'enableVoting → enable_voting', + true, + rawMongoosePost.enable_voting, + ); +} + +// --------------------------------------------------------------------------- +// MessageRoom parity +// --------------------------------------------------------------------------- + +async function checkMessageRoom(): Promise { + console.log('\n💬 MessageRoom model parity'); + + const userA = new mongoose.Types.ObjectId(); + const userB = new mongoose.Types.ObjectId(); + + // --- Mongoose → Prisma + const mongooseRoom = await MessageRoom.create({ + users: [userA, userB], + messageType: 'USER', + isDirect: true, + }); + created.push({ kind: 'mongoose', model: 'MessageRoom', id: String(mongooseRoom._id) }); + + const prismaRoomView = await prisma.messageRoom.findUnique({ + where: { id: String(mongooseRoom._id) }, + }); + if (!prismaRoomView) { + throw new Error(`Prisma could not find MessageRoom ${mongooseRoom._id} created via Mongoose`); + } + + assertEqual( + 'MessageRoom', + 'Mongoose → Prisma', + 'users → userIds', + [String(userA), String(userB)], + prismaRoomView.userIds, + ); + + // --- Prisma → Mongoose + const prismaRoom = await prisma.messageRoom.create({ + data: { + userIds: [String(userA)], + messageType: 'USER', + isDirect: true, + }, + }); + created.push({ kind: 'prisma', model: 'messageRoom', id: prismaRoom.id }); + + const rawMongooseRoom = (await MessageRoom.findById(prismaRoom.id).lean()) as unknown as { + users?: mongoose.Types.ObjectId[]; + } | null; + if (!rawMongooseRoom) { + throw new Error(`Mongoose could not find MessageRoom ${prismaRoom.id} created via Prisma`); + } + + assertEqual( + 'MessageRoom', + 'Prisma → Mongoose', + 'userIds → users', + [String(userA)], + (rawMongooseRoom.users ?? []).map(String), + ); +} + +// --------------------------------------------------------------------------- +// Functional parity — compare Mongoose populate() vs Prisma include() +// Ensures the same logical query returns equivalent data shapes. +// --------------------------------------------------------------------------- + +async function checkFunctionalParity(suffix: string): Promise { + console.log('\n🔗 Functional parity — Mongoose populate vs Prisma include'); + + // 1. Seed via Mongoose: user + group + post + 2 comments + const author = await User.create({ + email: `func-author-${suffix}@parity.test`, + username: `func_author_${suffix}`, + password: 'not-a-real-password', + }); + created.push({ kind: 'mongoose', model: 'User', id: String(author._id) }); + + const group = await Group.create({ + creatorId: author._id, + title: `Func group ${suffix}`, + privacy: 'public', + }); + created.push({ kind: 'mongoose', model: 'Group', id: String(group._id) }); + + const post = await Post.create({ + userId: author._id, + groupId: group._id, + title: 'Functional parity post', + text: 'Body', + }); + created.push({ kind: 'mongoose', model: 'Post', id: String(post._id) }); + + // 2. Read the same document via both ORMs — verify the fields visible + // through Mongoose's populate() match the fields returned by Prisma's + // include(). We cast minimally to avoid depending on internal + // Mongoose populate typings. + interface PopulatedPost { + _id: mongoose.Types.ObjectId; + title: string; + userId: { _id: mongoose.Types.ObjectId; username?: string } | null; + } + + const mongoosePostDoc = (await Post.findById(post._id) + .populate('userId', 'username') + .lean()) as unknown as PopulatedPost | null; + + const prismaPost = await prisma.post.findUnique({ + where: { id: String(post._id) }, + include: { user: { select: { id: true, username: true } } }, + }); + + if (!mongoosePostDoc || !prismaPost) { + throw new Error('Functional parity: post not found in one of the ORMs'); + } + + assertEqual( + 'FunctionalParity', + 'Mongoose → Prisma', + 'post.title matches', + mongoosePostDoc.title, + prismaPost.title, + ); + + assertEqual( + 'FunctionalParity', + 'Mongoose → Prisma', + 'populate(userId).username === include(user).username', + mongoosePostDoc.userId?.username, + prismaPost.user.username, + ); + + assertEqual( + 'FunctionalParity', + 'Mongoose → Prisma', + 'populated author id matches included user id', + String(mongoosePostDoc.userId?._id), + prismaPost.user.id, + ); + + // 3. Verify count parity for the posts collection + const mongooseCount = await Post.countDocuments({ userId: author._id }); + const prismaCount = await prisma.post.count({ where: { userId: String(author._id) } }); + + assertEqual( + 'FunctionalParity', + 'Mongoose → Prisma', + 'countDocuments === prisma.count', + mongooseCount, + prismaCount, + ); +} + +// --------------------------------------------------------------------------- +// Cleanup (LIFO) +// --------------------------------------------------------------------------- + +type PrismaDelegate = { + delete: (args: { where: { id: string } }) => Promise; +}; + +type PrismaModelKey = 'user' | 'post' | 'messageRoom'; + +function getPrismaDelegate(key: string): PrismaDelegate | null { + const map: Record = { + user: prisma.user, + post: prisma.post, + messageRoom: prisma.messageRoom, + }; + return map[key as PrismaModelKey] ?? null; +} + +type MongooseModelKey = 'User' | 'Post' | 'MessageRoom' | 'Group'; + +type MongooseModel = { deleteOne: (filter: { _id: string }) => { exec: () => Promise } }; + +function getMongooseModel(name: string): MongooseModel | null { + const map: Record = { + User, + Post, + MessageRoom, + Group, + }; + return map[name as MongooseModelKey] ?? null; +} + +async function cleanup(): Promise { + console.log('\n🧹 Cleaning up test documents...'); + for (let i = created.length - 1; i >= 0; i -= 1) { + const { kind, model, id } = created[i]; + try { + if (kind === 'prisma') { + const delegate = getPrismaDelegate(model); + if (delegate) await delegate.delete({ where: { id } }); + } else { + const mongooseModel = getMongooseModel(model); + if (mongooseModel) await mongooseModel.deleteOne({ _id: id }).exec(); + } + } catch (err) { + console.log(` ⚠️ Cleanup failed for ${kind}/${model}/${id}: ${(err as Error).message}`); + } + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + const mongoUri = process.env.MONGO_URI ?? process.env.DATABASE_URL; + if (!mongoUri) { + console.error('❌ MONGO_URI or DATABASE_URL must be set in .env'); + process.exit(1); + } + + console.log('🔍 Prisma ↔ Mongoose Parity Check\n'); + console.log('📡 Connecting...'); + + await mongoose.connect(mongoUri); + await prisma.$connect(); + console.log('✅ Connected to MongoDB (both Mongoose and Prisma)'); + + const suffix = `${Date.now()}`; + + try { + await checkUser(suffix); + await checkPost(suffix); + await checkMessageRoom(); + await checkFunctionalParity(suffix); + } finally { + await cleanup(); + await prisma.$disconnect(); + await mongoose.disconnect(); + console.log('🔌 Disconnected'); + } + + // Report + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + + console.log('\n═══════════════════════════════════════════'); + console.log(`📊 Parity Check Results: ${passed} passed, ${failed} failed`); + console.log('═══════════════════════════════════════════'); + + if (failed > 0) { + console.log('\n❌ FAILED — Prisma and Mongoose are not in sync'); + process.exit(1); + } + + console.log('\n🎉 PASSED — Prisma and Mongoose are fully aligned'); +} + +function isReplicaSetError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err); + return /replica set/i.test(msg) || /P2031/.test(msg); +} + +main().catch((err: Error) => { + if (isReplicaSetError(err)) { + console.error( + '\n⚠️ MongoDB is not running as a replica set.\n' + + ' Prisma requires a replica set because create/update/delete use transactions.\n' + + ' Fix (fastest): docker run -d --name mongo-rs -p 27017:27017 \\\n' + + ' mongo:7 --replSet rs0 --bind_ip_all\n' + + ' docker exec -it mongo-rs mongosh --eval "rs.initiate()"\n' + + ' Update DATABASE_URL to include ?replicaSet=rs0&directConnection=true\n' + + ' See docs/prisma-migration-strategy.md for all options.\n', + ); + process.exit(1); + } + console.error('\n💥 Parity check crashed:'); + console.error(err); + process.exit(1); +});