feat: SaaS foundation for CrowdSight
Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation: - Local memory backend (Zep-compatible): memory services/models, local graph builder + updater, AgentActivity seam, import-boundary isolation; Zep stays default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven. - Durable product persistence: projects/simulations/reports schema (migration 0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project read-first + ArtifactStore abstraction; durable JobQueue + worker.py. - SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM accounting), redacted AuditService, idempotency, CORS allowlist, safe API errors, single-use PasswordResetService + endpoints (covers invite-pending). - Exactly 3 roles (super_admin/admin/user) with tenant authz policy. - Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with admin/super-admin route guards, th/en i18n. - Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep. Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap). No commit of credentials; secrets handled via env/.env.example. Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel container build of deploy topology.
This commit is contained in:
71
frontend/tests/i18n-contract.test.mjs
Normal file
71
frontend/tests/i18n-contract.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../..')
|
||||
const read = relativePath => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')
|
||||
const parseJson = relativePath => JSON.parse(read(relativePath))
|
||||
|
||||
const flattenKeys = (value, prefix = '') => {
|
||||
const keys = []
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key
|
||||
if (child && typeof child === 'object' && !Array.isArray(child)) {
|
||||
keys.push(...flattenKeys(child, fullKey))
|
||||
} else {
|
||||
keys.push(fullKey)
|
||||
}
|
||||
}
|
||||
return keys.sort()
|
||||
}
|
||||
|
||||
test('locale registry exposes only Thai and English', () => {
|
||||
const languages = parseJson('locales/languages.json')
|
||||
assert.deepEqual(Object.keys(languages).sort(), ['en', 'th'])
|
||||
assert.equal(languages.en.label, 'English')
|
||||
assert.equal(languages.th.label, 'ไทย')
|
||||
})
|
||||
|
||||
test('Thai and English translation dictionaries have the same keys', () => {
|
||||
assert.deepEqual(
|
||||
flattenKeys(parseJson('locales/en.json')),
|
||||
flattenKeys(parseJson('locales/th.json')),
|
||||
)
|
||||
})
|
||||
|
||||
test('frontend i18n uses Thai as the safe default and fallback', () => {
|
||||
const source = read('frontend/src/i18n/index.js')
|
||||
assert.match(source, /DEFAULT_LOCALE\s*=\s*['"]th['"]/)
|
||||
assert.match(source, /fallbackLocale:\s*DEFAULT_LOCALE/)
|
||||
assert.doesNotMatch(source, /\|\|\s*['"]zh['"]|fallbackLocale:\s*['"]zh['"]/)
|
||||
})
|
||||
|
||||
test('HTML metadata has no Chinese locale or font dependency', () => {
|
||||
const html = read('frontend/index.html')
|
||||
assert.match(html, /<html\s+lang=["']th["']/)
|
||||
assert.doesNotMatch(html, /lang=["']zh|Noto Sans SC|预测|社交媒体|中文/)
|
||||
})
|
||||
|
||||
test('production build text artifacts contain no CJK code points', () => {
|
||||
const distRoot = path.join(repoRoot, 'frontend/dist')
|
||||
const binaryExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico'])
|
||||
const textFiles = []
|
||||
|
||||
const visit = directory => {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const entryPath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
visit(entryPath)
|
||||
} else if (!binaryExtensions.has(path.extname(entry.name).toLowerCase())) {
|
||||
textFiles.push(entryPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(distRoot)
|
||||
for (const filePath of textFiles) {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
assert.doesNotMatch(content, /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/, filePath)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user