Files
what-to-eat/test/cookFeedbackFunction.test.ts

148 lines
5.8 KiB
TypeScript

import { createRequire } from 'node:module'
import { beforeEach, describe, expect, it } from 'vitest'
interface FunctionModule {
main: (event?: Record<string, unknown>) => Promise<Record<string, any>>
_internal: {
DAILY_QUOTA: number
FEEDBACK: string
QUOTA: string
SIGNALS: string
setAppForTest: (app: unknown) => void
}
}
const require = createRequire(import.meta.url)
const fn = require('../cloudfunctions/cook-feedback/index.js') as FunctionModule
type Doc = Record<string, any>
/** In-memory stand-in for the parts of the CloudBase database the function uses. */
function createApp(uid = '') {
const store = new Map<string, Map<string, Doc>>()
const table = (name: string) => {
if (!store.has(name))
store.set(name, new Map())
return store.get(name)!
}
const command = {
inc: (n: number) => ({ $inc: n }),
gt: (n: number) => ({ $gt: n }),
}
const query = (name: string, cond: Doc) => {
let skip = 0
let limit = Infinity
const api = {
skip: (n: number) => {
skip = n
return api
},
limit: (n: number) => {
limit = n
return api
},
get: async () => {
const rows = [...table(name)].map(([id, doc]) => ({ _id: id, ...doc })).filter(doc => Object.entries(cond).every(([k, v]) => v && typeof v === 'object' && '$gt' in v ? doc[k] > v.$gt : doc[k] === v))
return { data: rows.slice(skip, skip + limit) }
},
}
return api
}
const collection = (name: string) => ({
doc: (id: string) => ({
get: async () => ({ data: table(name).has(id) ? [{ _id: id, ...table(name).get(id) }] : [] }),
set: async (data: Doc) => {
table(name).set(id, { ...data })
return {}
},
update: async (data: Doc) => {
const current = table(name).get(id)
if (!current)
return { updated: 0 }
for (const [key, value] of Object.entries(data))
current[key] = value && typeof value === 'object' && '$inc' in value ? (current[key] ?? 0) + value.$inc : value
return { updated: 1 }
},
}),
where: (cond: Doc) => query(name, cond),
...query(name, {}),
})
fn._internal.setAppForTest({
auth: () => ({ getUserInfo: () => ({ uid }) }),
database: () => ({ collection, command }),
})
return { table }
}
const deviceId = 'device-0123456789abcdef'
const report = { action: 'report', deviceId, recipeId: 3, recipeName: '番茄炒蛋', kind: 'video' }
describe('cook-feedback function', () => {
beforeEach(() => {
delete process.env.COOK_FEEDBACK_EXPORT_TOKEN
})
it('stores anonymous reports keyed by device, once per recipe and kind per day', async () => {
const { table } = createApp()
expect(await fn.main(report)).toEqual({ ok: true })
expect(await fn.main({ ...report, note: '视频没了' })).toEqual({ ok: true })
const rows = [...table(fn._internal.FEEDBACK).values()]
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ recipeId: 3, kind: 'video', note: '视频没了', uid: '' })
expect(JSON.stringify(rows[0])).not.toContain(deviceId)
})
it('keeps the uid for signed-in reporters', async () => {
const { table } = createApp('user-1')
await fn.main({ ...report, deviceId: undefined })
expect([...table(fn._internal.FEEDBACK).values()][0]).toMatchObject({ uid: 'user-1' })
})
it('rejects calls without a reporter or with bad input', async () => {
createApp()
expect(await fn.main({ ...report, deviceId: 'short' })).toEqual({ ok: false, code: 'REPORTER_REQUIRED' })
expect(await fn.main({ ...report, kind: 'spam' })).toEqual({ ok: false, code: 'INVALID_REPORT' })
expect(await fn.main({ ...report, recipeId: -1 })).toEqual({ ok: false, code: 'INVALID_REPORT' })
expect(await fn.main({ action: 'nope', deviceId })).toEqual({ ok: false, code: 'UNKNOWN_ACTION' })
})
it('aggregates search signals', async () => {
const { table } = createApp()
const signal = { action: 'signal', deviceId, type: 'open', stuff: ['鸡蛋', '番茄'], mode: 'loose', recipeId: 3, rank: 4 }
await fn.main(signal)
await fn.main({ ...signal, stuff: ['番茄', '鸡蛋'], rank: 2 })
await fn.main({ action: 'signal', deviceId, type: 'empty', stuff: ['羊肉'] })
const rows = [...table(fn._internal.SIGNALS).values()]
expect(rows).toHaveLength(2)
expect(rows.find(r => r.type === 'open')).toMatchObject({ stuff: ['番茄', '鸡蛋'], recipeId: 3, count: 2, rankSum: 6 })
expect(rows.find(r => r.type === 'empty')).toMatchObject({ stuff: ['羊肉'], recipeId: -1, count: 1 })
expect(await fn.main({ ...signal, rank: 5000 })).toEqual({ ok: false, code: 'INVALID_SIGNAL' })
})
it('rate limits a reporter per day', async () => {
const { table } = createApp()
await fn.main(report)
const [quota] = [...table(fn._internal.QUOTA).values()]
quota!.count = fn._internal.DAILY_QUOTA
expect(await fn.main(report)).toEqual({ ok: false, code: 'RATE_LIMITED' })
})
it('exports only over HTTP with the right token', async () => {
createApp()
await fn.main(report)
process.env.COOK_FEEDBACK_EXPORT_TOKEN = 'secret-token'
expect((await fn.main({ httpMethod: 'GET', headers: { 'x-cook-token': 'wrong-token!' } })).statusCode).toBe(401)
expect((await fn.main({ httpMethod: 'GET', headers: {} })).statusCode).toBe(401)
const res = await fn.main({ httpMethod: 'GET', headers: { 'x-cook-token': 'secret-token' } })
expect(res.statusCode).toBe(200)
const body = JSON.parse(res.body)
expect(body.feedback[0]).toMatchObject({ recipeId: 3, signedIn: false })
expect(body.feedback[0].uid).toBeUndefined()
})
it('refuses the export when no token is configured', async () => {
createApp()
expect((await fn.main({ httpMethod: 'GET', headers: { 'x-cook-token': '' } })).statusCode).toBe(401)
})
})