#!/usr/bin/env node 'use strict' // Exercises the installed mobile client's real store code without modifying or starting the app. // Run: node --test scripts/test-moderation-client.cjs [CLIENT_REPO may be provided in the environment]. const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const vm = require('node:vm') const { test } = require('node:test') const clientRoot = process.env.CLIENT_REPO || path.resolve(__dirname, '../../../Friendship-and-chat-uniapp') const storePath = path.join(clientRoot, 'src/chat-store/chatStore.js') const enumsPath = path.join(clientRoot, 'src/common/enums.js') const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value)) const chatInfo = { type: 'PRIVATE', targetId: 7 } function harness() { const memory = new Map() const context = vm.createContext({ defineStore: (_name, definition) => definition, useUserStore: () => ({ userInfo: { id: 42 } }), useFriendStore: () => ({ findFriend: () => null }), useGroupStore: () => ({ findGroup: () => null }), console: { log() {} }, uni: { setStorageSync: (key, value) => memory.set(key, clone(value)), getStorageSync: (key) => clone(memory.get(key)), removeStorageSync: (key) => memory.delete(key) } }) const enumSource = fs.readFileSync(enumsPath, 'utf8').replace(/export\s*\{[\s\S]*?\}\s*;?\s*$/, '') vm.runInContext(enumSource, context, { filename: enumsPath }) const storeSource = fs.readFileSync(storePath, 'utf8') .replace(/^import\s+.*?;?\s*$/gm, '') .replace('export default defineStore', 'globalThis.definition = defineStore') vm.runInContext(storeSource, context, { filename: storePath }) const definition = context.definition const store = definition.state() for (const [key, action] of Object.entries(definition.actions)) store[key] = action.bind(store) for (const [key, getter] of Object.entries(definition.getters)) { Object.defineProperty(store, key, { get: () => getter(store) }) } return { store, memory } } function message(id, content = 'original private content') { return { id, type: 0, status: 3, content, sendId: 7, recvId: 42, sendTime: 1000 + id, selfSend: false } } function quote(id) { return { ...message(id, 'reply text'), quoteMessage: message(101) } } function conversation(messages, hotMinIdx = 0) { return { ...chatInfo, showName: 'Sender', messages, hotMinIdx, unreadCount: 2, readedMessageIdx: 0, lastContent: 'original private content', lastSendTime: 1101, stored: false, hidden: false } } function recall(selfSend = false) { // The persisted event has its own ID; its legacy-client wire projection must use the source ID. return { id: 101, content: '101', type: 10, status: 3, sendId: 7, recvId: 42, sendTime: 2000, selfSend } } function assertHidden(value, expectedText = '对方撤回了一条消息') { assert.equal(value.type, 21) assert.equal(value.status, 2) assert.equal(value.content, expectedText) } for (const { name, split, messages } of [ { name: 'hot source and hot quote', split: 0, messages: [message(101), quote(102)] }, { name: 'cold source and hot quote', split: 1, messages: [message(101), quote(102)] }, { name: 'cold source and cold quote', split: 2, messages: [message(101), quote(102), message(103, 'later message')] } ]) { test(`legacy RECALL hides ${name} and persists both affected partitions`, () => { const { store, memory } = harness() const chat = conversation(clone(messages), split) store.chats = [chat] store.saveToStorage(true) store.recallMessage(recall(), chatInfo) assertHidden(chat.messages[0]) assertHidden(chat.messages[1].quoteMessage, '引用内容已撤回') const cold = memory.get('chats-app-42-PRIVATE-7')?.messages || [] const hot = memory.get('chats-app-42-PRIVATE-7-hot')?.messages || [] const persisted = [...cold, ...hot] assertHidden(persisted.find((item) => item.id === 101)) assertHidden(persisted.find((item) => item.id === 102).quoteMessage, '引用内容已撤回') assert.equal(chat.unreadCount, 2) assert.equal(chat.lastContent, '对方撤回了一条消息') }) } test('repeat notifications with status 3 do not add unread counts or duplicate rows', () => { const { store, memory } = harness() const chat = conversation([message(101), quote(102)], 1) store.chats = [chat] store.saveToStorage(true) store.recallMessage(recall(), chatInfo) store.recallMessage(recall(), chatInfo) assert.equal(chat.unreadCount, 2) assert.equal(chat.messages.length, 2) assert.equal(memory.get('chats-app-42-PRIVATE-7-hot').unreadCount, 2) assertHidden(chat.messages[0]) }) test('sender receives the same compatible projection and sees their own recall tip', () => { const { store } = harness() const chat = conversation([message(101)]) store.chats = [chat] store.recallMessage(recall(true), chatInfo) assertHidden(chat.messages[0], '你撤回了一条消息') assert.equal(chat.unreadCount, 2) }) test('offline buffer processing persists hidden source and quotes after refresh', () => { const { store, memory } = harness() store.initChats({ chats: [conversation([message(101), quote(102)], 2)], privateMsgMaxId: 102 }) store.setLoading(true) store.recallMessage(recall(), chatInfo) assert.equal(memory.size, 0) store.setLoading(false) store.refreshChats() assertHidden(store.chats[0].messages[0]) assertHidden(store.chats[0].messages[1].quoteMessage, '引用内容已撤回') const cold = memory.get('chats-app-42-PRIVATE-7').messages assertHidden(cold[0]) assertHidden(cold[1].quoteMessage, '引用内容已撤回') assert.equal(store.chats[0].unreadCount, 2) }) test('unknown conversation is ignored without creating empty chats or unread counts', () => { const { store, memory } = harness() assert.doesNotThrow(() => store.recallMessage(recall(), chatInfo)) assert.equal(store.chats.length, 0) assert.equal(memory.size, 0) })