mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
Fire a 'ai-usage-limit-reached' event in backend GitOrigin-RevId: b43babdd2a0146086cee5ebd2bde83f8daaf1bb5
383 lines
13 KiB
JavaScript
383 lines
13 KiB
JavaScript
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import sinon from 'sinon'
|
|
import mongodb from 'mongodb-legacy'
|
|
import Errors from '../../../../app/src/Features/Errors/Errors.js'
|
|
import {
|
|
connectionPromise,
|
|
cleanupTestDatabase,
|
|
} from '../../../../app/src/infrastructure/mongodb.mjs'
|
|
import { UserFeatureUsage } from '../../../../app/src/models/UserFeatureUsage.mjs'
|
|
|
|
const { TooManyRequestsError } = Errors
|
|
const ObjectId = mongodb.ObjectId
|
|
|
|
vi.mock('../../../../app/src/Features/Errors/Errors.js', () => {
|
|
return vi.importActual('../../../../app/src/Features/Errors/Errors.js')
|
|
})
|
|
|
|
// NOTE: Needs to be an allowed field in UserFeatureUsageSchema
|
|
const MOCKED_FEATURE_NAME = 'aiWorkbench'
|
|
|
|
const modulePath =
|
|
'../../../../app/src/infrastructure/rate-limiters/FeatureUsageRateLimiter'
|
|
|
|
describe('FeatureUsageRateLimiter', function () {
|
|
beforeAll(async function () {
|
|
await connectionPromise
|
|
})
|
|
beforeEach(cleanupTestDatabase)
|
|
|
|
beforeEach(async function (ctx) {
|
|
ctx.userId = new ObjectId().toString()
|
|
|
|
const FeatureUsageRateLimiterClass = (await import(modulePath)).default
|
|
ctx._getAllowanceStub = sinon.stub()
|
|
|
|
class FeatureUsageTestRateLimiter extends FeatureUsageRateLimiterClass {
|
|
constructor() {
|
|
super(MOCKED_FEATURE_NAME)
|
|
}
|
|
|
|
_getAllowance = ctx._getAllowanceStub
|
|
}
|
|
|
|
ctx.FeatureUsageRateLimiter = new FeatureUsageTestRateLimiter()
|
|
})
|
|
|
|
describe('useFeature', function () {
|
|
beforeEach(function (ctx) {
|
|
ctx._getAllowanceStub.resolves(100)
|
|
})
|
|
|
|
describe('with no usage', function (ctx) {
|
|
it('should succeed', async function (ctx) {
|
|
const res = { set: () => null }
|
|
await expect(ctx.FeatureUsageRateLimiter.useFeature(ctx.userId, res, 1))
|
|
.to.not.be.rejected
|
|
})
|
|
})
|
|
|
|
describe('with some remaining allowance left', function () {
|
|
beforeEach(async function (ctx) {
|
|
await UserFeatureUsage.create({
|
|
_id: ctx.userId,
|
|
features: {
|
|
[MOCKED_FEATURE_NAME]: { usage: 50, periodStart: new Date() },
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should suceed', async function (ctx) {
|
|
const res = { set: () => null }
|
|
await expect(ctx.FeatureUsageRateLimiter.useFeature(ctx.userId, res, 1))
|
|
.to.not.be.rejected
|
|
})
|
|
})
|
|
|
|
describe('with 0 allowance left', function () {
|
|
beforeEach(async function (ctx) {
|
|
await UserFeatureUsage.create({
|
|
_id: ctx.userId,
|
|
features: {
|
|
[MOCKED_FEATURE_NAME]: { usage: 101, periodStart: new Date() },
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should be rejected with TooManyRequestsError', async function (ctx) {
|
|
const res = { set: () => null }
|
|
await expect(
|
|
ctx.FeatureUsageRateLimiter.useFeature(ctx.userId, res, 1)
|
|
).to.be.rejectedWith(TooManyRequestsError)
|
|
})
|
|
})
|
|
|
|
describe('with cost=0', function () {
|
|
beforeEach(async function (ctx) {
|
|
await UserFeatureUsage.create({
|
|
_id: ctx.userId,
|
|
features: {
|
|
[MOCKED_FEATURE_NAME]: { usage: 50, periodStart: new Date() },
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should not increment usage', async function (ctx) {
|
|
const res = { set: () => null }
|
|
await ctx.FeatureUsageRateLimiter.useFeature(ctx.userId, res, 0)
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(50)
|
|
})
|
|
|
|
it('should still be rejected when over the limit', async function (ctx) {
|
|
await UserFeatureUsage.findOneAndUpdate(
|
|
{ _id: ctx.userId },
|
|
{ $set: { [`features.${MOCKED_FEATURE_NAME}.usage`]: 101 } }
|
|
)
|
|
const res = { set: () => null }
|
|
await expect(
|
|
ctx.FeatureUsageRateLimiter.useFeature(ctx.userId, res, 0)
|
|
).to.be.rejectedWith(TooManyRequestsError)
|
|
})
|
|
})
|
|
|
|
describe('with cost greater than 1', function () {
|
|
it('should increment usage by the specified cost', async function (ctx) {
|
|
const res = { set: () => null }
|
|
await ctx.FeatureUsageRateLimiter.useFeature(ctx.userId, res, 5)
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(95)
|
|
})
|
|
})
|
|
|
|
describe('with default cost parameter', function () {
|
|
it('should increment usage by 1 when cost is omitted', async function (ctx) {
|
|
const res = { set: () => null }
|
|
await ctx.FeatureUsageRateLimiter.useFeature(ctx.userId, res)
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(99)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('_recordFirstLimitReach', function () {
|
|
beforeEach(async function (ctx) {
|
|
ctx.periodStart = new Date()
|
|
await UserFeatureUsage.create({
|
|
_id: ctx.userId,
|
|
features: {
|
|
[MOCKED_FEATURE_NAME]: { usage: 101, periodStart: ctx.periodStart },
|
|
},
|
|
})
|
|
})
|
|
|
|
it('returns true for the first reach of the period', async function (ctx) {
|
|
expect(
|
|
await ctx.FeatureUsageRateLimiter._recordFirstLimitReach(
|
|
ctx.userId,
|
|
ctx.periodStart
|
|
)
|
|
).to.be.true
|
|
})
|
|
|
|
it('returns false for later reaches in the same period', async function (ctx) {
|
|
await ctx.FeatureUsageRateLimiter._recordFirstLimitReach(
|
|
ctx.userId,
|
|
ctx.periodStart
|
|
)
|
|
|
|
expect(
|
|
await ctx.FeatureUsageRateLimiter._recordFirstLimitReach(
|
|
ctx.userId,
|
|
ctx.periodStart
|
|
)
|
|
).to.be.false
|
|
expect(
|
|
await ctx.FeatureUsageRateLimiter._recordFirstLimitReach(
|
|
ctx.userId,
|
|
ctx.periodStart
|
|
)
|
|
).to.be.false
|
|
})
|
|
|
|
it('returns true again once the period resets to a new periodStart', async function (ctx) {
|
|
await ctx.FeatureUsageRateLimiter._recordFirstLimitReach(
|
|
ctx.userId,
|
|
ctx.periodStart
|
|
)
|
|
|
|
const newPeriodStart = new Date(ctx.periodStart.getTime() + 1000)
|
|
expect(
|
|
await ctx.FeatureUsageRateLimiter._recordFirstLimitReach(
|
|
ctx.userId,
|
|
newPeriodStart
|
|
)
|
|
).to.be.true
|
|
})
|
|
})
|
|
|
|
describe('getRemainingFeatureUses', function () {
|
|
beforeEach(function (ctx) {
|
|
ctx._getAllowanceStub.resolves(100)
|
|
})
|
|
|
|
describe('with no usage', function () {
|
|
it('should return the whole allowance', async function (ctx) {
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(100)
|
|
})
|
|
})
|
|
|
|
describe('with some usage', function () {
|
|
beforeEach(async function (ctx) {
|
|
await UserFeatureUsage.create({
|
|
_id: ctx.userId,
|
|
features: {
|
|
[MOCKED_FEATURE_NAME]: { usage: 30, periodStart: new Date() },
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should return the correct remaining allowance', async function (ctx) {
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(70)
|
|
expect(
|
|
new Date(usages[MOCKED_FEATURE_NAME].resetDate).getTime()
|
|
).to.be.greaterThan(Date.now())
|
|
})
|
|
})
|
|
|
|
describe('at the period boundary', function () {
|
|
const PERIOD_MS = 24 * 60 * 60 * 1000
|
|
|
|
it('just inside the period returns usesLeft and future resetDate', async function (ctx) {
|
|
// whole second: `resetDate` round-trips through Date.toString()
|
|
const periodStart = new Date(
|
|
Math.floor((Date.now() - PERIOD_MS + 60 * 1000) / 1000) * 1000
|
|
)
|
|
await UserFeatureUsage.create({
|
|
_id: ctx.userId,
|
|
features: {
|
|
[MOCKED_FEATURE_NAME]: { usage: 40, periodStart },
|
|
},
|
|
})
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(60)
|
|
expect(
|
|
new Date(usages[MOCKED_FEATURE_NAME].resetDate).getTime()
|
|
).to.equal(periodStart.getTime() + PERIOD_MS)
|
|
})
|
|
|
|
it('once the period has lapsed reports the full allowance and a fresh resetDate', async function (ctx) {
|
|
// whole second: `resetDate` round-trips through Date.toString()
|
|
const periodStart = new Date(
|
|
Math.floor((Date.now() - PERIOD_MS - 60 * 1000) / 1000) * 1000
|
|
)
|
|
await UserFeatureUsage.create({
|
|
_id: ctx.userId,
|
|
features: {
|
|
[MOCKED_FEATURE_NAME]: { usage: 40, periodStart },
|
|
},
|
|
})
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(100)
|
|
expect(
|
|
new Date(usages[MOCKED_FEATURE_NAME].resetDate).getTime()
|
|
).to.be.approximately(Date.now() + PERIOD_MS, 5000)
|
|
})
|
|
})
|
|
|
|
describe('when usage exceeds allowance', function () {
|
|
beforeEach(async function (ctx) {
|
|
await UserFeatureUsage.create({
|
|
_id: ctx.userId,
|
|
features: {
|
|
[MOCKED_FEATURE_NAME]: { usage: 150, periodStart: new Date() },
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should clamp remainingUsage to 0 rather than leak a negative', async function (ctx) {
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(0)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('resetFeatureUsage', function () {
|
|
beforeEach(function (ctx) {
|
|
ctx._getAllowanceStub.resolves(100)
|
|
})
|
|
|
|
describe('with some usage', function () {
|
|
beforeEach(async function (ctx) {
|
|
await UserFeatureUsage.create({
|
|
_id: ctx.userId,
|
|
features: {
|
|
[MOCKED_FEATURE_NAME]: { usage: 75, periodStart: new Date(0) },
|
|
},
|
|
})
|
|
})
|
|
|
|
it('should reset usage back to the full allowance', async function (ctx) {
|
|
await ctx.FeatureUsageRateLimiter.resetFeatureUsage(ctx.userId)
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(100)
|
|
})
|
|
|
|
it('should set periodStart to roughly the current time', async function (ctx) {
|
|
const before = Date.now()
|
|
await ctx.FeatureUsageRateLimiter.resetFeatureUsage(ctx.userId)
|
|
const doc = await UserFeatureUsage.findOne({ _id: ctx.userId }).exec()
|
|
const periodStart = doc.features[MOCKED_FEATURE_NAME].periodStart
|
|
expect(periodStart.getTime()).to.be.at.least(before)
|
|
expect(periodStart.getTime()).to.be.at.most(Date.now())
|
|
})
|
|
})
|
|
|
|
describe('when no usage record exists', function () {
|
|
it('should upsert a fresh usage record with zero usage', async function (ctx) {
|
|
await ctx.FeatureUsageRateLimiter.resetFeatureUsage(ctx.userId)
|
|
const doc = await UserFeatureUsage.findOne({ _id: ctx.userId }).exec()
|
|
expect(doc).to.not.be.null
|
|
expect(doc.features[MOCKED_FEATURE_NAME].usage).to.equal(0)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('decrementFeatureUsage', function () {
|
|
describe('with some usage', function () {
|
|
beforeEach(async function (ctx) {
|
|
await UserFeatureUsage.create({
|
|
_id: ctx.userId,
|
|
features: {
|
|
[MOCKED_FEATURE_NAME]: { usage: 30, periodStart: new Date() },
|
|
},
|
|
})
|
|
ctx._getAllowanceStub.resolves(100)
|
|
})
|
|
|
|
it('should decrement usage by 1 when cost is 1', async function (ctx) {
|
|
const res = { set: () => null }
|
|
await ctx.FeatureUsageRateLimiter.decrementFeatureUsage(
|
|
ctx.userId,
|
|
res,
|
|
1
|
|
)
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(71)
|
|
})
|
|
|
|
it('should decrement usage by the specified cost', async function (ctx) {
|
|
const res = { set: () => null }
|
|
await ctx.FeatureUsageRateLimiter.decrementFeatureUsage(
|
|
ctx.userId,
|
|
res,
|
|
5
|
|
)
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(75)
|
|
})
|
|
|
|
it('should decrement usage by 1 when cost is omitted (default)', async function (ctx) {
|
|
const res = { set: () => null }
|
|
await ctx.FeatureUsageRateLimiter.decrementFeatureUsage(ctx.userId, res)
|
|
const usages =
|
|
await ctx.FeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
|
expect(usages[MOCKED_FEATURE_NAME].remainingUsage).to.equal(71)
|
|
})
|
|
})
|
|
})
|
|
})
|