diff --git a/prisma/hyperdrive.prisma b/prisma/hyperdrive.prisma index b102815..6b885c3 100644 --- a/prisma/hyperdrive.prisma +++ b/prisma/hyperdrive.prisma @@ -332,3 +332,17 @@ model sr_user_bookmark_overview { @@index([bookmark_id, user_id]) } + +// Per-user switches for Labs features. One row per (user, feature); no row means off. +// created_at = first time the user touched the switch, updated_at = last change. +model sr_user_lab_feature { + id Int @id @default(autoincrement()) + user_id Int + feature String + enabled Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@unique([user_id, feature]) + @@index([feature, enabled]) +} diff --git a/prisma/migrations/20260908000000_add_user_lab_feature/migration.sql b/prisma/migrations/20260908000000_add_user_lab_feature/migration.sql new file mode 100644 index 0000000..6bcc2e6 --- /dev/null +++ b/prisma/migrations/20260908000000_add_user_lab_feature/migration.sql @@ -0,0 +1,15 @@ +-- sr_user_lab_feature: per-user switches for Labs features (no row = off) +CREATE TABLE "sr_user_lab_feature" ( + "id" SERIAL NOT NULL, + "user_id" INTEGER NOT NULL, + "feature" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sr_user_lab_feature_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "sr_user_lab_feature_user_id_feature_key" ON "sr_user_lab_feature"("user_id", "feature"); + +CREATE INDEX "sr_user_lab_feature_feature_enabled_idx" ON "sr_user_lab_feature"("feature", "enabled"); diff --git a/src/const/err.ts b/src/const/err.ts index 6df21a7..3d89851 100644 --- a/src/const/err.ts +++ b/src/const/err.ts @@ -72,7 +72,8 @@ export enum ErrorName { IMPORT_OTHER_TIMEOUT = 'IMPORT_OTHER_TIMEOUT', BOOKMARK_OVERVIEW_CONTENT_ERROR = 'BOOKMARK_OVERVIEW_CONTENT_ERROR', SYNC_TABLE_RULE_ERROR = 'SYNC_TABLE_RULE_ERROR', - SYNC_TABLE_TAG_NAME_ERROR = 'SYNC_TABLE_TAG_NAME_ERROR' + SYNC_TABLE_TAG_NAME_ERROR = 'SYNC_TABLE_TAG_NAME_ERROR', + LAB_FEATURE_DISABLED = 'LAB_FEATURE_DISABLED' } const translations: { [key in Language]: Partial> } = { @@ -91,6 +92,7 @@ const translations: { [key in Language]: Partial> } = [ErrorName.TRASH_REVERT_BOOKMARK_FAIL]: '移出垃圾篓失败', [ErrorName.BOOKMARK_NOT_FOUND]: '书签未找到', [ErrorName.BLOCK_TARGET_URL]: '目标网址被阻止', + [ErrorName.LAB_FEATURE_DISABLED]: '{feature}还在实验室里,请到设置页打开后再保存', [ErrorName.PROHIBITED_CONTENT]: '处理失败:内容被禁止', [ErrorName.SHARE_CONTENT_NOT_SUPPORTED]: '该内容类型不支持分享', [ErrorName.CREATE_BOOKMARK_FAIL]: '创建书签失败', @@ -139,6 +141,7 @@ const translations: { [key in Language]: Partial> } = [ErrorName.TRASH_REVERT_BOOKMARK_FAIL]: 'Revert bookmark failed', [ErrorName.BOOKMARK_NOT_FOUND]: 'Bookmark not found', [ErrorName.BLOCK_TARGET_URL]: 'Blocked target url', + [ErrorName.LAB_FEATURE_DISABLED]: '{feature} are still in Labs. Turn it on in Settings, then save again', [ErrorName.PROHIBITED_CONTENT]: 'Processing failed: prohibited content', [ErrorName.SHARE_CONTENT_NOT_SUPPORTED]: 'This type of content cannot be shared', [ErrorName.CREATE_BOOKMARK_FAIL]: 'Create bookmark failed', @@ -198,6 +201,7 @@ const translations: { [key in Language]: Partial> } = [ErrorName.SERVER_ERROR]: 'Error interno del servidor', [ErrorName.GOOGLE_SSO_ERROR]: 'Error de Google SSO', [ErrorName.PROHIBITED_CONTENT]: 'Error de procesamiento: contenido prohibido', + [ErrorName.LAB_FEATURE_DISABLED]: '{feature} todavía están en el Laboratorio. Actívalo en Ajustes y vuelve a guardar', [ErrorName.SHARE_CONTENT_NOT_SUPPORTED]: 'Este tipo de contenido no se puede compartir' } } @@ -292,3 +296,11 @@ export const ImportOtherTimeoutError = (): MultiLangError => NewError(ErrorName. export const BookmarkOverviewContentError = (): MultiLangError => NewError(ErrorName.BOOKMARK_OVERVIEW_CONTENT_ERROR, 500) export const SyncTableRuleError = (): MultiLangError => NewError(ErrorName.SYNC_TABLE_RULE_ERROR, 400) export const SyncTableTagNameError = (): MultiLangError => NewError(ErrorName.SYNC_TABLE_TAG_NAME_ERROR, 400) +/** Message carries the feature's display name so clients can show it as-is. */ +export const LabFeatureDisabledError = (featureName: { [lang in Language]?: string }): MultiLangError => { + const messages = loadErrorMessages(ErrorName.LAB_FEATURE_DISABLED) + for (const lang of Object.keys(messages) as Language[]) { + messages[lang] = (messages[lang] || '').replace('{feature}', featureName[lang] || featureName.en || '') + } + return new MultiLangError(ErrorName.LAB_FEATURE_DISABLED, 400, messages) +} diff --git a/src/di/generated/dependency.ts b/src/di/generated/dependency.ts index a4c2b27..d310edf 100644 --- a/src/di/generated/dependency.ts +++ b/src/di/generated/dependency.ts @@ -19,7 +19,9 @@ import { VectorizeRepo } from '../../infra/repository/dbVectorize' import { MarkRepo } from '../../infra/repository/dbMark' import { UserRepo } from '../../infra/repository/dbUser' import { NotificationMessage } from '../../infra/message/notification' +import { LabRepo } from '../../infra/repository/dbLab' import { ReportRepo } from '../../infra/repository/dbReport' +import { LabService } from '../../domain/lab' import { BookmarkService } from '../../domain/bookmark' import { TagService } from '../../domain/tag' import { MarkService } from '../../domain/mark' @@ -88,6 +90,10 @@ container.register(ImportService, { ) }) +container.register(LabService, { + useFactory: container => new LabService(container.resolve(LabRepo)) +}) + container.register(MarkService, { useFactory: container => new MarkService(container.resolve(BookmarkRepo), container.resolve(MarkRepo), container.resolve(UserRepo)) }) @@ -117,7 +123,8 @@ container.register(UserService, { new UserService( container.resolve(UserRepo), lazy(() => container.resolve(BucketClient)), - container.resolve(ReportRepo) + container.resolve(ReportRepo), + container.resolve(LabService) ) }) @@ -204,6 +211,10 @@ container.register(BookmarkSearchRepo, { useFactory: container => new BookmarkSearchRepo(lazy(() => container.resolve(PRISIMA_FULLTEXT_CLIENT))) }) +container.register(LabRepo, { + useFactory: container => new LabRepo(lazy(() => container.resolve(PRISIMA_HYPERDRIVE_CLIENT))) +}) + container.register(MarkRepo, { useFactory: container => new MarkRepo( @@ -285,7 +296,8 @@ container.register(TagController, { }) container.register(UserController, { - useFactory: container => new UserController(container.resolve(UserService), container.resolve(BookmarkService), container.resolve(NotificationService)) + useFactory: container => + new UserController(container.resolve(UserService), container.resolve(BookmarkService), container.resolve(NotificationService), container.resolve(LabService)) }) container.register(DatabaseRegistry, { diff --git a/src/di/generated/readerRouter.ts b/src/di/generated/readerRouter.ts index e771316..57df431 100644 --- a/src/di/generated/readerRouter.ts +++ b/src/di/generated/readerRouter.ts @@ -245,6 +245,10 @@ export function getRouter(container: Container) { const controller = container.resolve(UserController) return await controller.handleDisableUserSettingRequest(ctx, req) }) + router.get('/v1/user/labs', async (req: Request, ctx: ContextManager) => { + const controller = container.resolve(UserController) + return await controller.handleUserLabsRequest(ctx, req) + }) router.get('/v1/user/userinfo', async (req: Request, ctx: ContextManager) => { const controller = container.resolve(UserController) return await controller.handleUserInfoRequest(ctx, req) diff --git a/src/domain/lab.ts b/src/domain/lab.ts new file mode 100644 index 0000000..036ce0d --- /dev/null +++ b/src/domain/lab.ts @@ -0,0 +1,100 @@ +import { inject, injectable } from '../decorators/di' +import { LabRepo } from '../infra/repository/dbLab' +import { ErrorName, ErrorParam, LabFeatureDisabledError } from '../const/err' +import { MultiLangError, Language } from '../utils/multiLangError' +import { ContextManager } from '../utils/context' + +export type LabFeatureStatus = 'active' | 'graduated' | 'retired' + +export interface LabFeatureDef { + status: LabFeatureStatus + /** Display name per language, spliced into the "still in Labs" error message */ + name: { [lang in Language]?: string } + /** ISO date the feature left Labs; the settings page hides graduated rows after a while */ + graduatedAt?: string +} + +export interface LabFeatureItem { + key: string + status: Exclude + enabled: boolean + enabled_at: string | null +} + +/** + * Labs: per-user switches for features that are still being tried out. + * + * The registry is empty in the open-source build. A hosted backend subclasses this + * service and overrides `features()` (and `gatedFeatureForUrl()` to block saves). + */ +@injectable() +export class LabService { + constructor(@inject(LabRepo) private labRepo: LabRepo) {} + + /** Feature registry. Keys are what clients send as `lab:`. */ + protected features(): Record { + return {} + } + + /** Which Labs feature (if any) a save of this URL needs. null = not gated. */ + protected gatedFeatureForUrl(_url: string): string | null { + return null + } + + /** Rows for the settings page: active and graduated only. */ + public async listForUser(userId: number): Promise { + const defs = this.features() + const keys = Object.keys(defs).filter(key => defs[key].status !== 'retired') + if (keys.length === 0) return [] + + const rows = await this.labRepo.listByUser(userId) + const byKey = new Map(rows.map(row => [row.feature, row])) + + return keys.map(key => { + const def = defs[key] + const row = byKey.get(key) + const enabled = def.status === 'graduated' ? true : (row?.enabled ?? false) + return { + key, + status: def.status as Exclude, + enabled, + enabled_at: row?.enabled ? row.updated_at.toISOString() : null + } + }) + } + + /** Unknown or retired keys are rejected; graduated ones are written but never read. */ + public async setEnabled(userId: number, key: string, enabled: boolean): Promise { + const def = this.features()[key] + if (!def || def.status === 'retired') throw ErrorParam() + await this.labRepo.upsert(userId, key, enabled) + } + + public async isEnabled(userId: number, key: string): Promise { + const def = this.features()[key] + if (!def || def.status === 'retired') return false + if (def.status === 'graduated') return true + return this.labRepo.isEnabled(userId, key) + } + + /** Throws LAB_FEATURE_DISABLED when the URL needs a Labs feature the user has not turned on. */ + public async assertUrlAllowed(ctx: ContextManager, url: string): Promise { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return // invalid URLs are reported by the existing save logic + } + const key = this.gatedFeatureForUrl(parsed.toString()) + if (!key) return + if (!(await this.isEnabled(ctx.getUserId(), key))) throw this.disabledError(key) + } + + public disabledError(key: string): MultiLangError { + return LabFeatureDisabledError(this.features()[key]?.name ?? { en: key }) + } + + public static isLabDisabledError(e: unknown): boolean { + return e instanceof MultiLangError && e.name === ErrorName.LAB_FEATURE_DISABLED + } +} diff --git a/src/domain/user.ts b/src/domain/user.ts index f0c2afe..0c49c79 100644 --- a/src/domain/user.ts +++ b/src/domain/user.ts @@ -12,6 +12,7 @@ import type { LazyInstance } from '../decorators/lazy' import { UserRepo } from '../infra/repository/dbUser' import { BucketClient } from '../infra/repository/bucketClient' import { ReportRepo } from '../infra/repository/dbReport' +import { LabService } from './lab' export interface userShareCollectInfo { show_name: string @@ -107,7 +108,8 @@ export class UserService { constructor( @inject(UserRepo) private userRepo: UserRepo, @inject(BucketClient) private bucketClient: LazyInstance, - @inject(ReportRepo) private reportRepo: ReportRepo + @inject(ReportRepo) private reportRepo: ReportRepo, + @inject(LabService) private labService: LabService ) {} /** @@ -335,6 +337,12 @@ export class UserService { public async enableUserSetting(ctx: ContextManager, setting: string, enable: boolean): Promise { // const user = await this.userRepo.getInfoByUserId(ctx.getUserId()) + // Labs switches share this endpoint: key "lab:" + if (setting.startsWith('lab:')) { + await this.labService.setEnabled(ctx.getUserId(), setting.slice(4), enable) + return 'ok' + } + if (setting === 'mail_collect' && !enable) { await this.userRepo.unbindPlatform(ctx.getUserId(), platformBindType.EMAIL) } diff --git a/src/handler/http/userController.ts b/src/handler/http/userController.ts index 399b542..7dc82e9 100644 --- a/src/handler/http/userController.ts +++ b/src/handler/http/userController.ts @@ -10,13 +10,15 @@ import { inject } from '../../decorators/di' import { BookmarkService } from '../../domain/bookmark' import { NotificationService } from '../../domain/notification' import { UserService } from '../../domain/user' +import { LabService } from '../../domain/lab' @Controller('/v1/user') export class UserController { constructor( @inject(UserService) private userService: UserService, @inject(BookmarkService) private bookmarkService: BookmarkService, - @inject(NotificationService) private notificationService: NotificationService + @inject(NotificationService) private notificationService: NotificationService, + @inject(LabService) private labService: LabService ) {} /** @@ -116,6 +118,15 @@ export class UserController { return Successed(res) } + /** + * 实验室功能列表(含当前用户的开关状态) + */ + @Get('/labs') + public async handleUserLabsRequest(ctx: ContextManager, request: Request) { + const features = await this.labService.listForUser(ctx.getUserId()) + return Successed({ features }) + } + /** * 获取用户信息 */ diff --git a/src/infra/repository/dbLab.ts b/src/infra/repository/dbLab.ts new file mode 100644 index 0000000..5416603 --- /dev/null +++ b/src/infra/repository/dbLab.ts @@ -0,0 +1,38 @@ +import { inject, singleton } from '../../decorators/di' +import { PRISIMA_HYPERDRIVE_CLIENT } from '../../const/symbol' +import type { LazyInstance } from '../../decorators/lazy' +import { PrismaClient as HyperdrivePrismaClient } from '@prisma/hyperdrive-client' + +export interface userLabFeaturePO { + id: number + user_id: number + feature: string + enabled: boolean + created_at: Date + updated_at: Date +} + +@singleton() +export class LabRepo { + constructor(@inject(PRISIMA_HYPERDRIVE_CLIENT) private prismaPg: LazyInstance) {} + + public async listByUser(userId: number): Promise { + return this.prismaPg().sr_user_lab_feature.findMany({ where: { user_id: userId } }) + } + + public async isEnabled(userId: number, feature: string): Promise { + const row = await this.prismaPg().sr_user_lab_feature.findUnique({ + where: { user_id_feature: { user_id: userId, feature } }, + select: { enabled: true } + }) + return row?.enabled ?? false + } + + public async upsert(userId: number, feature: string, enabled: boolean): Promise { + return this.prismaPg().sr_user_lab_feature.upsert({ + where: { user_id_feature: { user_id: userId, feature } }, + create: { user_id: userId, feature, enabled }, + update: { enabled } + }) + } +} diff --git a/test/domain/labService.test.ts b/test/domain/labService.test.ts new file mode 100644 index 0000000..3a158f2 --- /dev/null +++ b/test/domain/labService.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { LabService, type LabFeatureDef } from '../../src/domain/lab' +import { ErrorName } from '../../src/const/err' +import { setGlobalLanguage } from '../../src/utils/multiLangError' + +const userId = 7 + +function createRepo() { + return { + listByUser: vi.fn().mockResolvedValue([]), + isEnabled: vi.fn().mockResolvedValue(false), + upsert: vi.fn().mockResolvedValue(undefined) + } +} + +class TestLabService extends LabService { + constructor( + repo: any, + private defs: Record, + private gated: Record = {} + ) { + super(repo) + } + protected features() { + return this.defs + } + protected gatedFeatureForUrl(url: string) { + return Object.entries(this.gated).find(([host]) => new URL(url).hostname === host)?.[1] ?? null + } +} + +const defs: Record = { + youtube: { status: 'active', name: { zh: 'YouTube 视频', en: 'YouTube videos' } }, + pdf: { status: 'graduated', name: { en: 'PDF files' }, graduatedAt: '2026-09-01' }, + podcast: { status: 'retired', name: { en: 'Podcasts' } } +} + +describe('LabService with an empty registry (open-source default)', () => { + test('lists nothing, blocks nothing, rejects every key', async () => { + const repo = createRepo() + const service = new LabService(repo as any) + + expect(await service.listForUser(userId)).toEqual([]) + expect(repo.listByUser).not.toHaveBeenCalled() + expect(await service.isEnabled(userId, 'youtube')).toBe(false) + await expect(service.setEnabled(userId, 'youtube', true)).rejects.toMatchObject({ name: ErrorName.ERROR_PARAM }) + await expect(service.assertUrlAllowed({ getUserId: () => userId } as any, 'https://www.youtube.com/watch?v=abc')).resolves.toBeUndefined() + }) +}) + +describe('LabService.isEnabled', () => { + test('active reads the user row, missing row means off', async () => { + const repo = createRepo() + const service = new TestLabService(repo, defs) + + expect(await service.isEnabled(userId, 'youtube')).toBe(false) + expect(repo.isEnabled).toHaveBeenCalledWith(userId, 'youtube') + + repo.isEnabled.mockResolvedValue(true) + expect(await service.isEnabled(userId, 'youtube')).toBe(true) + }) + + test('graduated is on for everyone, retired and unknown are off, without touching the table', async () => { + const repo = createRepo() + const service = new TestLabService(repo, defs) + + expect(await service.isEnabled(userId, 'pdf')).toBe(true) + expect(await service.isEnabled(userId, 'podcast')).toBe(false) + expect(await service.isEnabled(userId, 'nope')).toBe(false) + expect(repo.isEnabled).not.toHaveBeenCalled() + }) +}) + +describe('LabService.setEnabled', () => { + test('writes active and graduated keys, rejects retired and unknown keys', async () => { + const repo = createRepo() + const service = new TestLabService(repo, defs) + + await service.setEnabled(userId, 'youtube', true) + expect(repo.upsert).toHaveBeenCalledWith(userId, 'youtube', true) + + await service.setEnabled(userId, 'pdf', false) + expect(repo.upsert).toHaveBeenCalledWith(userId, 'pdf', false) + + await expect(service.setEnabled(userId, 'podcast', true)).rejects.toMatchObject({ name: ErrorName.ERROR_PARAM }) + await expect(service.setEnabled(userId, 'nope', true)).rejects.toMatchObject({ name: ErrorName.ERROR_PARAM }) + expect(repo.upsert).toHaveBeenCalledTimes(2) + }) +}) + +describe('LabService.listForUser', () => { + test('returns active and graduated rows with the user state, hides retired', async () => { + const repo = createRepo() + const enabledAt = new Date('2026-09-08T01:02:03.000Z') + repo.listByUser.mockResolvedValue([ + { feature: 'youtube', enabled: true, created_at: enabledAt, updated_at: enabledAt }, + { feature: 'podcast', enabled: true, created_at: enabledAt, updated_at: enabledAt } + ]) + const service = new TestLabService(repo, defs) + + expect(await service.listForUser(userId)).toEqual([ + { key: 'youtube', status: 'active', enabled: true, enabled_at: '2026-09-08T01:02:03.000Z' }, + { key: 'pdf', status: 'graduated', enabled: true, enabled_at: null } + ]) + }) + + test('a switched-off row reports enabled=false with no enabled_at', async () => { + const repo = createRepo() + const at = new Date('2026-09-08T01:02:03.000Z') + repo.listByUser.mockResolvedValue([{ feature: 'youtube', enabled: false, created_at: at, updated_at: at }]) + const service = new TestLabService(repo, defs) + + const [youtube] = await service.listForUser(userId) + expect(youtube).toEqual({ key: 'youtube', status: 'active', enabled: false, enabled_at: null }) + }) +}) + +describe('LabService.assertUrlAllowed', () => { + const ctx = { getUserId: () => userId } as any + const gated = { 'www.youtube.com': 'youtube', 'files.example.com': 'pdf', 'pod.example.com': 'podcast' } + + beforeEach(() => setGlobalLanguage('en')) + + test('throws LAB_FEATURE_DISABLED with the feature name when the switch is off', async () => { + const service = new TestLabService(createRepo(), defs, gated) + + const err = await service.assertUrlAllowed(ctx, 'https://www.youtube.com/watch?v=abc').catch(e => e) + expect(err.name).toBe(ErrorName.LAB_FEATURE_DISABLED) + expect(err.errCode).toBe(400) + expect(err.getMessage).toBe('YouTube videos are still in Labs. Turn it on in Settings, then save again') + expect(LabService.isLabDisabledError(err)).toBe(true) + + setGlobalLanguage('zh') + expect(err.getMessage).toBe('YouTube 视频还在实验室里,请到设置页打开后再保存') + }) + + test('passes when the switch is on, when the feature graduated, and for ungated or invalid URLs', async () => { + const repo = createRepo() + repo.isEnabled.mockResolvedValue(true) + const service = new TestLabService(repo, defs, gated) + + await expect(service.assertUrlAllowed(ctx, 'https://www.youtube.com/watch?v=abc')).resolves.toBeUndefined() + await expect(service.assertUrlAllowed(ctx, 'https://files.example.com/a.pdf')).resolves.toBeUndefined() + await expect(service.assertUrlAllowed(ctx, 'https://example.com/post')).resolves.toBeUndefined() + await expect(service.assertUrlAllowed(ctx, 'not a url')).resolves.toBeUndefined() + }) + + test('a retired feature blocks the URL for everyone', async () => { + const service = new TestLabService(createRepo(), defs, gated) + await expect(service.assertUrlAllowed(ctx, 'https://pod.example.com/ep1')).rejects.toMatchObject({ name: ErrorName.LAB_FEATURE_DISABLED }) + }) + + test('isLabDisabledError ignores other errors', () => { + expect(LabService.isLabDisabledError(new Error('x'))).toBe(false) + expect(LabService.isLabDisabledError(null)).toBe(false) + }) +}) diff --git a/test/domain/userSettingLab.test.ts b/test/domain/userSettingLab.test.ts new file mode 100644 index 0000000..d35a0b0 --- /dev/null +++ b/test/domain/userSettingLab.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test, vi } from 'vitest' +import { UserService } from '../../src/domain/user' + +function createService() { + const labService = { setEnabled: vi.fn().mockResolvedValue(undefined) } + const userRepo = { unbindPlatform: vi.fn(), getInfoByUserId: vi.fn() } + const service = Object.create(UserService.prototype) as UserService + Object.assign(service, { labService, userRepo }) + return { service, labService, userRepo } +} + +const ctx = { getUserId: () => 7 } as never + +describe('UserService.enableUserSetting with a lab: key', () => { + test('enable forwards the feature key to LabService', async () => { + const { service, labService, userRepo } = createService() + expect(await service.enableUserSetting(ctx, 'lab:youtube', true)).toBe('ok') + expect(labService.setEnabled).toHaveBeenCalledWith(7, 'youtube', true) + expect(userRepo.unbindPlatform).not.toHaveBeenCalled() + }) + + test('disable forwards enable=false', async () => { + const { service, labService } = createService() + await service.enableUserSetting(ctx, 'lab:youtube', false) + expect(labService.setEnabled).toHaveBeenCalledWith(7, 'youtube', false) + }) + + test('other keys never reach LabService', async () => { + const { service, labService } = createService() + await service.enableUserSetting(ctx, 'mail_collect', false) + expect(labService.setEnabled).not.toHaveBeenCalled() + }) +})