mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(addons): addon catalog and photo providers through repositories
This commit is contained in:
@@ -1,4 +1,64 @@
|
||||
import type { Addons } from '../entities/Addons.entity';
|
||||
import type { Addons, AddonConfig } from '../entities/Addons.entity';
|
||||
import { toRow, type AssertRowKeys } from './_shared/rows';
|
||||
import { EntityRepository } from '@mikro-orm/sql';
|
||||
|
||||
export class AddonsRepository extends EntityRepository<Addons> {}
|
||||
/** An `addons` row as the API emits it. */
|
||||
export interface AddonRow {
|
||||
id: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
icon: string | null;
|
||||
enabled: boolean | null;
|
||||
config: AddonConfig | null;
|
||||
sort_order: number | null;
|
||||
}
|
||||
|
||||
const _addonRowKeys: AssertRowKeys<AddonRow, Addons> = true;
|
||||
|
||||
export class AddonsRepository extends EntityRepository<Addons> {
|
||||
/**
|
||||
* `SELECT enabled FROM addons WHERE id = ?`
|
||||
*
|
||||
* `refresh: true` (Task 0 review, I1 — carried forward for every PK read in
|
||||
* this plan): a primary-key `findOne` answers a repeat call from the
|
||||
* identity map, invisible to a write on the same id inside the same
|
||||
* request (`setEnabled`, below, or `admin.service.ts`'s still-raw
|
||||
* `UPDATE addons SET enabled = ?` on the same connection).
|
||||
*/
|
||||
async isEnabled(id: string): Promise<boolean> {
|
||||
const row = await this.findOne({ id }, { fields: ['enabled'], refresh: true });
|
||||
return !!row?.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1
|
||||
* ORDER BY sort_order` — this reads every scalar column (not just the
|
||||
* legacy statement's five), because `AddonsService.list()` already
|
||||
* explicitly picks the five fields it forwards to the client; narrowing the
|
||||
* query itself would need a second, `AssertRowKeys`-incompatible row type
|
||||
* for no parity benefit.
|
||||
*/
|
||||
async listEnabled(): Promise<AddonRow[]> {
|
||||
const rows = await this.find({ enabled: true }, { orderBy: { sort_order: 'asc' } });
|
||||
return rows.map((row) => toRow(row) as AddonRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* `UPDATE addons SET enabled = ? WHERE id = ?` — the write half of the
|
||||
* enablement toggle `admin.service.ts` still issues as raw SQL
|
||||
* (`updateAddon`, outside this plan's six domains). Declared here, on the
|
||||
* repository that owns the `addons` table, so that conversion has a
|
||||
* ready-made method rather than inventing its own; nothing in this domain
|
||||
* calls it yet.
|
||||
*
|
||||
* `enabled` is `p.boolean()` on the entity, so `nativeUpdate` stores the
|
||||
* legacy `1`/`0` through MikroORM's own boolean type mapper — the
|
||||
* repository test asserts the raw stored value directly, and that a
|
||||
* following read (with `refresh: true`) comes back as a JS boolean, not
|
||||
* the raw int.
|
||||
*/
|
||||
async setEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,36 @@
|
||||
import type { PhotoProviderFields } from '../entities/PhotoProviderFields.entity';
|
||||
import { toRow, type AssertRowKeys } from './_shared/rows';
|
||||
import { EntityRepository } from '@mikro-orm/sql';
|
||||
|
||||
export class PhotoProviderFieldsRepository extends EntityRepository<PhotoProviderFields> {}
|
||||
/** A `photo_provider_fields` row as the API emits it. */
|
||||
export interface PhotoProviderFieldRow {
|
||||
id: number;
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder: string | null;
|
||||
hint: string | null;
|
||||
required: number | null;
|
||||
secret: number | null;
|
||||
settings_key: string | null;
|
||||
payload_key: string | null;
|
||||
sort_order: number | null;
|
||||
}
|
||||
|
||||
const _photoProviderFieldRowKeys: AssertRowKeys<PhotoProviderFieldRow, PhotoProviderFields> = true;
|
||||
|
||||
export class PhotoProviderFieldsRepository extends EntityRepository<PhotoProviderFields> {
|
||||
/**
|
||||
* `SELECT provider_id, field_key, label, input_type, placeholder, hint,
|
||||
* required, secret, settings_key, payload_key, sort_order FROM
|
||||
* photo_provider_fields ORDER BY sort_order, id` — `provider_id` is the
|
||||
* `persist(false)` twin of the `provider` relation (Days.trip_id
|
||||
* precedent): reading it back after `find()` needs no extra populate, only
|
||||
* a `create()`/twin write would.
|
||||
*/
|
||||
async listAllOrdered(): Promise<PhotoProviderFieldRow[]> {
|
||||
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } });
|
||||
return rows.map((row) => toRow(row) as PhotoProviderFieldRow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,29 @@
|
||||
import type { PhotoProviders } from '../entities/PhotoProviders.entity';
|
||||
import { toRow, type AssertRowKeys } from './_shared/rows';
|
||||
import { EntityRepository } from '@mikro-orm/sql';
|
||||
|
||||
export class PhotoProvidersRepository extends EntityRepository<PhotoProviders> {}
|
||||
/** A `photo_providers` row as the API emits it. */
|
||||
export interface PhotoProviderRow {
|
||||
id: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
enabled: number | null;
|
||||
sort_order: number | null;
|
||||
}
|
||||
|
||||
const _photoProviderRowKeys: AssertRowKeys<PhotoProviderRow, PhotoProviders> = true;
|
||||
|
||||
export class PhotoProvidersRepository extends EntityRepository<PhotoProviders> {
|
||||
/**
|
||||
* `SELECT id, name, icon, enabled, sort_order FROM photo_providers WHERE
|
||||
* enabled = 1 ORDER BY sort_order, id` — `enabled` is `p.integer()` on this
|
||||
* entity (unlike `Addons.enabled`, which is `p.boolean()`), so it comes
|
||||
* back as the raw stored `0`/`1`; `AddonsService.list()` does the `!!`
|
||||
* coercion itself, matching the legacy `!!p.enabled`.
|
||||
*/
|
||||
async listEnabled(): Promise<PhotoProviderRow[]> {
|
||||
const rows = await this.find({ enabled: 1 }, { orderBy: { sort_order: 'asc', id: 'asc' } });
|
||||
return rows.map((row) => toRow(row) as PhotoProviderRow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { AddonsController } from './addons.controller';
|
||||
import { AddonsService } from './addons.service';
|
||||
import { AddonGuard } from './addon.guard';
|
||||
import { AddonsMcp } from './addons.mcp';
|
||||
import { Addons } from '../../db/entities/Addons.entity';
|
||||
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
|
||||
import { PhotoProviderFields } from '../../db/entities/PhotoProviderFields.entity';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
|
||||
/**
|
||||
* GET /api/addons — enabled add-ons + photo providers (was an inline handler in
|
||||
@@ -18,8 +23,14 @@ import { AddonsMcp } from './addons.mcp';
|
||||
* AddonsMcp puts the same listing on the MCP surface, so a model can read which
|
||||
* add-ons this instance has switched on instead of guessing from the tools it
|
||||
* was handed.
|
||||
*
|
||||
* MikroOrmModule.forFeature registers AddonsRepository/PhotoProvidersRepository/
|
||||
* PhotoProviderFieldsRepository/AppSettingsRepository for AddonsService's
|
||||
* @InjectRepository constructor (Plan 3a Task 4) — the forFeature +
|
||||
* @InjectRepository wiring pattern Task 0 set up on SettingsModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([Addons, PhotoProviders, PhotoProviderFields, AppSettings])],
|
||||
controllers: [AddonsController],
|
||||
providers: [AddonsService, AddonGuard, AddonsMcp],
|
||||
exports: [AddonsService, AddonGuard],
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import type { Addon } from '../../types';
|
||||
import { getPhotoProviderConfig } from '../memories/memories.helpers';
|
||||
import { readTransitProvider, writeTransitProvider } from '../transit/transit-provider';
|
||||
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
|
||||
import { readEnv } from '../../app-config';
|
||||
import type { TransitProvider } from '@trek/shared';
|
||||
import { Addons } from '../../db/entities/Addons.entity';
|
||||
import type { AddonsRepository } from '../../db/repositories/Addons.repository';
|
||||
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
|
||||
import type { PhotoProvidersRepository, PhotoProviderRow } from '../../db/repositories/PhotoProviders.repository';
|
||||
import { PhotoProviderFields } from '../../db/entities/PhotoProviderFields.entity';
|
||||
import type { PhotoProviderFieldsRepository, PhotoProviderFieldRow } from '../../db/repositories/PhotoProviderFields.repository';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
|
||||
/**
|
||||
* Thin wrapper around the enabled-addons + photo-provider read that the legacy
|
||||
* inline `GET /api/addons` handler performed (server/src/app.ts). The SQL,
|
||||
* inline `GET /api/addons` handler performed (server/src/app.ts). The
|
||||
* ordering, boolean coercions and the merged photo-provider entries are
|
||||
* reproduced 1:1 so the body is byte-identical for the client.
|
||||
*
|
||||
@@ -19,54 +27,77 @@ import type { TransitProvider } from '@trek/shared';
|
||||
* bag-tracking and collab-features flags. The boolean polarities differ on
|
||||
* purpose — bag tracking is opt-in (`=== 'true'`, default OFF), the collab
|
||||
* sub-features are opt-out (`!== 'false'`, default ON). Reads are uncached
|
||||
* per-call queries so admin toggles stay immediately visible.
|
||||
* per-call repository calls so admin toggles stay immediately visible.
|
||||
*
|
||||
* `DatabaseService` stays injected (Plan 3a Task 4) purely as a passthrough
|
||||
* for two cross-domain functions this service still calls: `transit-provider.ts`'s
|
||||
* readTransitProvider/writeTransitProvider (nest/transit, not one of this
|
||||
* plan's six domains) and `instance-api-keys.ts`'s resolveApiKey (nest/settings,
|
||||
* Task 5's own conversion). Neither reads/writes through `this.db` in THIS
|
||||
* file — every `app_settings`/`addons`/`photo_providers`/`photo_provider_fields`
|
||||
* site this service itself used to touch is repository-backed below — so this
|
||||
* is the same kind of carve-out `permissions`' two guard delegations are: `grep
|
||||
* DatabaseService src/nest/addons` still finds this constructor parameter and
|
||||
* its import, not a raw query.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AddonsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
constructor(
|
||||
@InjectRepository(Addons) private readonly addons: AddonsRepository,
|
||||
@InjectRepository(PhotoProviders) private readonly photoProviders: PhotoProvidersRepository,
|
||||
@InjectRepository(PhotoProviderFields) private readonly photoProviderFields: PhotoProviderFieldsRepository,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
private readonly dbs: DatabaseService,
|
||||
) {}
|
||||
|
||||
async isAddonEnabled(addonId: string): Promise<boolean> {
|
||||
const addon = this.db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as
|
||||
| { enabled: number }
|
||||
| undefined;
|
||||
return !!addon?.enabled;
|
||||
return this.addons.isEnabled(addonId);
|
||||
}
|
||||
|
||||
async getBagTracking() {
|
||||
const row = this.db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return { enabled: row?.value === 'true' };
|
||||
const value = await this.appSettings.getValue('bag_tracking_enabled');
|
||||
return { enabled: value === 'true' };
|
||||
}
|
||||
|
||||
async updateBagTracking(enabled: boolean) {
|
||||
this.db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(
|
||||
enabled ? 'true' : 'false',
|
||||
);
|
||||
await this.appSettings.setValue('bag_tracking_enabled', enabled ? 'true' : 'false');
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
/**
|
||||
* `AppSettingsRepository.getValues` drops a key from the returned `Map`
|
||||
* both when no row exists AND when the row's `value` is `NULL` (Task 0
|
||||
* concern #4) — this method's polarity (`!== 'false'`, fail-open) makes
|
||||
* that safe: a dropped key reads `map.get(key) === undefined`, and
|
||||
* `undefined !== 'false'` is `true`, the exact same outcome the legacy
|
||||
* `Record<string,string>` produced for a `NULL` value (`null !== 'false'`
|
||||
* is also `true`). Confirmed, not assumed — see the repository test
|
||||
* ADDONSREPO/APPSETREPO parity note and this service's own test file.
|
||||
*/
|
||||
async getCollabFeatures() {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_links_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
|
||||
)
|
||||
.all() as { key: string; value: string }[];
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
const map = await this.appSettings.getValues([
|
||||
'collab_chat_enabled',
|
||||
'collab_notes_enabled',
|
||||
'collab_links_enabled',
|
||||
'collab_polls_enabled',
|
||||
'collab_whatsnext_enabled',
|
||||
]);
|
||||
return {
|
||||
chat: map['collab_chat_enabled'] !== 'false',
|
||||
notes: map['collab_notes_enabled'] !== 'false',
|
||||
links: map['collab_links_enabled'] !== 'false',
|
||||
polls: map['collab_polls_enabled'] !== 'false',
|
||||
whatsnext: map['collab_whatsnext_enabled'] !== 'false',
|
||||
chat: map.get('collab_chat_enabled') !== 'false',
|
||||
notes: map.get('collab_notes_enabled') !== 'false',
|
||||
links: map.get('collab_links_enabled') !== 'false',
|
||||
polls: map.get('collab_polls_enabled') !== 'false',
|
||||
whatsnext: map.get('collab_whatsnext_enabled') !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling (plan §"Do not fix legacy behaviour"): the legacy handler wrote up
|
||||
* to 5 `app_settings` rows one at a time, with no transaction around them —
|
||||
* this stays exactly that way. `AppSettingsRepository.setValue` imposes no
|
||||
* transaction of its own (Task 0 concern #5), so the loop below is still 0
|
||||
* to 5 independent, individually-committed upserts.
|
||||
*/
|
||||
async updateCollabFeatures(features: { chat?: boolean; notes?: boolean; links?: boolean; polls?: boolean; whatsnext?: boolean }) {
|
||||
const mapping: Record<string, string> = {
|
||||
chat: 'collab_chat_enabled',
|
||||
@@ -76,9 +107,9 @@ export class AddonsService {
|
||||
whatsnext: 'collab_whatsnext_enabled',
|
||||
};
|
||||
const before = await this.getCollabFeatures();
|
||||
const stmt = this.db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
|
||||
for (const [feat, key] of Object.entries(mapping)) {
|
||||
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
|
||||
const value = features[feat as keyof typeof features];
|
||||
if (value !== undefined) await this.appSettings.setValue(key, value ? 'true' : 'false');
|
||||
}
|
||||
const after = await this.getCollabFeatures();
|
||||
// Collab flags gate MCP tool/resource registration, so callers must know
|
||||
@@ -89,46 +120,19 @@ export class AddonsService {
|
||||
}
|
||||
|
||||
async list() {
|
||||
const addons = this.db
|
||||
.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order')
|
||||
.all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
||||
const addonRows = await this.addons.listEnabled();
|
||||
// Photo providers surface only inside journeys, so with the journey addon
|
||||
// off they are unavailable no matter what their own rows say. Deriving that
|
||||
// here (instead of a migration) also covers installs that still hold an
|
||||
// enabled provider under a disabled journey from before updateAddon
|
||||
// cascaded the disable.
|
||||
const providers = !(await this.isAddonEnabled(ADDON_IDS.JOURNEY))
|
||||
const providerRows: PhotoProviderRow[] = !(await this.isAddonEnabled(ADDON_IDS.JOURNEY))
|
||||
? []
|
||||
: (this.db
|
||||
.prepare(
|
||||
`SELECT id, name, icon, enabled, sort_order
|
||||
FROM photo_providers
|
||||
WHERE enabled = 1
|
||||
ORDER BY sort_order, id`,
|
||||
)
|
||||
.all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>);
|
||||
const fields = this.db
|
||||
.prepare(
|
||||
`SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
ORDER BY sort_order, id`,
|
||||
)
|
||||
.all() as Array<{
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder?: string | null;
|
||||
hint?: string | null;
|
||||
required: number;
|
||||
secret: number;
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order: number;
|
||||
}>;
|
||||
: await this.photoProviders.listEnabled();
|
||||
const fieldRows: PhotoProviderFieldRow[] = await this.photoProviderFields.listAllOrdered();
|
||||
|
||||
const fieldsByProvider = new Map<string, typeof fields>();
|
||||
for (const field of fields) {
|
||||
const fieldsByProvider = new Map<string, PhotoProviderFieldRow[]>();
|
||||
for (const field of fieldRows) {
|
||||
const arr = fieldsByProvider.get(field.provider_id) || [];
|
||||
arr.push(field);
|
||||
fieldsByProvider.set(field.provider_id, arr);
|
||||
@@ -138,15 +142,19 @@ export class AddonsService {
|
||||
collabFeatures: await this.getCollabFeatures(),
|
||||
bagTracking: (await this.getBagTracking()).enabled,
|
||||
addons: [
|
||||
...addons.map((a) => ({ ...a, enabled: !!a.enabled })),
|
||||
...providers.map((p) => ({
|
||||
// The repository row carries every scalar column (description, config,
|
||||
// sort_order included); the legacy statement selected only these five,
|
||||
// so the client-facing shape is picked explicitly here rather than
|
||||
// spread from the row.
|
||||
...addonRows.map((a) => ({ id: a.id, name: a.name, type: a.type, icon: a.icon, enabled: !!a.enabled })),
|
||||
...providerRows.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: 'photo_provider',
|
||||
icon: p.icon,
|
||||
enabled: !!p.enabled,
|
||||
config: getPhotoProviderConfig(p.id),
|
||||
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
|
||||
config: getPhotoProviderConfig(p.id ?? ''),
|
||||
fields: (fieldsByProvider.get(p.id ?? '') || []).map((f) => ({
|
||||
key: f.field_key,
|
||||
label: f.label,
|
||||
input_type: f.input_type,
|
||||
@@ -172,12 +180,12 @@ export class AddonsService {
|
||||
// loses a feature on upgrade.
|
||||
|
||||
private async readFlag(key: string) {
|
||||
const row = this.db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
return { enabled: row?.value === 'true' };
|
||||
const value = await this.appSettings.getValue(key);
|
||||
return { enabled: value === 'true' };
|
||||
}
|
||||
|
||||
private async writeFlag(key: string, enabled: boolean) {
|
||||
this.db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, enabled ? 'true' : 'false');
|
||||
await this.appSettings.setValue(key, enabled ? 'true' : 'false');
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
@@ -212,10 +220,8 @@ export class AddonsService {
|
||||
* "off" while the feature runs, which is worse than either default.
|
||||
*/
|
||||
async getPlacesEnrich() {
|
||||
const row = this.db.prepare("SELECT value FROM app_settings WHERE key = 'places_enrich_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
const value = await this.appSettings.getValue('places_enrich_enabled');
|
||||
return { enabled: value !== 'false' };
|
||||
}
|
||||
|
||||
async updatePlacesEnrich(enabled: boolean) { return this.writeFlag('places_enrich_enabled', enabled); }
|
||||
@@ -224,7 +230,10 @@ export class AddonsService {
|
||||
// Not a flag: two named backends, so it stores the name rather than a
|
||||
// boolean. The read/write pair lives in transit/transit-provider.ts because
|
||||
// TransitService reads the same row on every request — one key, one reader,
|
||||
// one writer.
|
||||
// one writer. Neither that module nor instance-api-keys.ts (below) is one of
|
||||
// this plan's six domains, so they stay on DatabaseService until their own
|
||||
// conversion (transit is outside Plan 3a entirely; instance-api-keys.ts is
|
||||
// Task 5's).
|
||||
|
||||
/**
|
||||
* Where the Google key would come from for this caller, or null if nowhere.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { DiscoveryMetadataService } from './discovery-metadata.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { withRequestContext } from '../database/request-context';
|
||||
|
||||
/**
|
||||
* The SDK discovery router plus its addon gate: 404 (empty body) on every
|
||||
@@ -16,29 +18,42 @@ import { ADDON_IDS } from '../../addons';
|
||||
* from the container by token (the httpConfig.KEY precedent). A factory
|
||||
* provider rather than a NestMiddleware class because nothing consumer-mounts
|
||||
* it: the deliverable is the bare Express RequestHandler itself.
|
||||
*
|
||||
* D6 boot rule (Plan 3a Task 4): a pathless pre-init `app.use` runs on the raw
|
||||
* Express instance, before `@mikro-orm/nestjs`'s per-request EntityManager
|
||||
* fork middleware — unlike `createMcpAddonGate`'s OTHER mount in
|
||||
* `oauth.module.ts`, which goes through a normal `MiddlewareConsumer.forRoutes()`
|
||||
* and so is already inside that fork. Once `AddonsService.isAddonEnabled`
|
||||
* became repository-backed, a request here threw MikroORM's
|
||||
* `cannotUseGlobalContext` (proven: every `/.well-known/*` integration test
|
||||
* failed with a 500 until this wrap). `withRequestContext` is this file's own
|
||||
* choke point — every request through this middleware, gated or not, gets a
|
||||
* context the same way the boot-time `getAdminUserDefaults()` read in
|
||||
* `bootstrap.ts` already does.
|
||||
*/
|
||||
export const MCP_METADATA_MIDDLEWARE = Symbol('MCP_METADATA_MIDDLEWARE');
|
||||
|
||||
export function createMcpMetadataMiddleware(
|
||||
meta: DiscoveryMetadataService,
|
||||
addons: AddonsService,
|
||||
orm: MikroORM,
|
||||
): RequestHandler {
|
||||
// Express cannot await a middleware, so the now-async addon check runs in a
|
||||
// helper and its rejection is handed to next() — the same error path a
|
||||
// synchronous throw took before (recipe R1.5).
|
||||
return (req, res, next) => {
|
||||
void (async () => {
|
||||
void withRequestContext(orm, async () => {
|
||||
if (req.path.startsWith('/.well-known/') && !(await addons.isAddonEnabled(ADDON_IDS.MCP))) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
meta.getMetaRouter()(req, res, next);
|
||||
})().catch(next);
|
||||
}).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
export const mcpMetadataMiddlewareProvider = {
|
||||
provide: MCP_METADATA_MIDDLEWARE,
|
||||
useFactory: createMcpMetadataMiddleware,
|
||||
inject: [DiscoveryMetadataService, AddonsService],
|
||||
inject: [DiscoveryMetadataService, AddonsService, MikroORM],
|
||||
};
|
||||
|
||||
@@ -20,8 +20,13 @@ const { db } = vi.hoisted(() => {
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, name TEXT, type TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE photo_providers (id TEXT PRIMARY KEY, name TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
|
||||
// description/config (addons) and description (photo_providers) are absent
|
||||
// from the legacy SELECTs this suite exercises, but the Addons/PhotoProviders
|
||||
// entities declare them as columns (Plan 3a Task 4) — MikroORM's generated
|
||||
// SELECT names every scalar column, so they must exist on this hand-rolled
|
||||
// schema even though no test here reads them off addons/photo_providers rows.
|
||||
tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, enabled INTEGER, config TEXT DEFAULT '{}', sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE photo_providers (id TEXT PRIMARY KEY, name TEXT, description TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE photo_provider_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT, field_key TEXT,
|
||||
label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER,
|
||||
settings_key TEXT, payload_key TEXT, sort_order INTEGER);`);
|
||||
@@ -41,13 +46,14 @@ vi.mock('../../src/nest/memories/memories.helpers', () => ({ getPhotoProviderCon
|
||||
import { AddonsModule } from '../../src/nest/addons/addons.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||||
|
||||
describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, AddonsModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, AddonsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -34,6 +34,7 @@ import { SystemNoticesModule } from '../../src/nest/system-notices/system-notice
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
||||
|
||||
const notice = {
|
||||
id: 'welcome', display: 'modal', severity: 'info',
|
||||
@@ -47,8 +48,10 @@ describe('System-notices e2e (real auth guard + temp SQLite)', () => {
|
||||
async function build() {
|
||||
// DatabaseModule is @Global in the real app; a partial graph has to
|
||||
// provide it for SystemNoticesModule's AddonsModule import (the
|
||||
// addons.e2e precedent).
|
||||
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), DatabaseModule, SystemNoticesModule] }).compile();
|
||||
// addons.e2e precedent). createTestMikroOrmModule for the same import's
|
||||
// MikroOrmModule.forFeature (Plan 3a Task 4) — no case here calls an
|
||||
// AddonsService method, so the minimal `users`-only schema above is enough.
|
||||
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), DatabaseModule, SystemNoticesModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -81,7 +81,7 @@ import { TripPromptsMcp } from '../../src/nest/trips/trip-prompts.mcp';
|
||||
import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from './notifications';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from './test-addons';
|
||||
import { RoadtripMcp } from '../../src/nest/roadtrip/roadtrip.mcp';
|
||||
import { RoadtripService } from '../../src/nest/roadtrip/roadtrip.service';
|
||||
import { notificationsStub } from './notifications';
|
||||
@@ -114,7 +114,7 @@ import { AirtrailImportService } from '../../src/nest/integrations/airtrail-impo
|
||||
import { ReservationImportMcp } from '../../src/nest/reservation-import/reservation-import.mcp';
|
||||
import { HelpMcp } from '../../src/nest/help/help.mcp';
|
||||
import { AddonsMcp } from '../../src/nest/addons/addons.mcp';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo } from './test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestCategoriesRepo, createTestTagsRepo } from './test-uow';
|
||||
import { createTestOrm } from './test-orm';
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository';
|
||||
@@ -210,7 +210,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
// rather than off addons.bridge's own instance, so the harness has to supply
|
||||
// one — against the same test DB, which is what makes the `when:` gates
|
||||
// answer truthfully here instead of against the process-wide singleton.
|
||||
const addonsService = new AddonsService(dbService);
|
||||
const addonsService = await createTestAddonsService(dbService.connection, dbService);
|
||||
// The two photo providers, shared by MemoriesMcp (which browses them) and by
|
||||
// the capture backfill JourneyMcp schedules after a provider photo is attached
|
||||
// (which asks them when and where it was taken). Built for real rather than
|
||||
@@ -222,8 +222,8 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
const captureBackfill = new PhotoCaptureBackfillService(new PhotoResolverService(trekPhotos, new ThumbnailService(addonsService, generalStorage, dbService), new TrekPhotoCacheService(dbService, generalStorage), new PhotoProviderRegistry([new ImmichPhotoProvider(immichService), new SynologyPhotoProvider(synologyService)]), generalStorage), trekPhotos, generalStorage);
|
||||
return createTestRegistry(
|
||||
[
|
||||
new TagsMcp(new TagsService(dbService), authService),
|
||||
new CategoriesMcp(new CategoriesService(dbService), dbService, new RuntimeEnvService(), guards),
|
||||
new TagsMcp(new TagsService(await createTestTagsRepo(dbService.connection)), authService),
|
||||
new CategoriesMcp(new CategoriesService(await createTestCategoriesRepo(dbService.connection)), dbService, new RuntimeEnvService(), guards),
|
||||
// The weather and airport tools left the legacy mapsWeather registrar.
|
||||
new WeatherMcp(new WeatherService()),
|
||||
new AirportsMcp(),
|
||||
|
||||
@@ -35,7 +35,7 @@ import { AtlasService } from '../../src/nest/atlas/atlas.service';
|
||||
import { MapsService } from '../../src/nest/maps/maps.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from './test-addons';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { QueryHelpersService } from '../../src/nest/query-helpers/query-helpers.service';
|
||||
import { TripMembershipService } from '../../src/nest/trip-membership/trip-membership.service';
|
||||
@@ -72,7 +72,7 @@ import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-
|
||||
import { TrekPhotosRepository } from '../../src/nest/photos/trek-photos.repository';
|
||||
import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service';
|
||||
import { makeStorageFixture } from './storage-fixture';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from './test-uow';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestCategoriesRepo, createTestTagsRepo, sharedTestOrm } from './test-uow';
|
||||
import { createTestOrm } from './test-orm';
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository';
|
||||
@@ -98,7 +98,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
|
||||
const exchangeRates = new ExchangeRatesService();
|
||||
const realtime = new RealtimeService();
|
||||
const budget = new BudgetService(dbs, permissions, exchangeRates, realtime, await createTestUnitOfWork(dbs.connection));
|
||||
const addons = new AddonsService(dbs);
|
||||
const addons = await createTestAddonsService(dbs.connection, dbs);
|
||||
const queryHelpers = new QueryHelpersService(dbs);
|
||||
const todos = new TodoService(dbs, permissions, realtime, await createTestUnitOfWork(dbs.connection));
|
||||
const packing = new PackingService(dbs, permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
|
||||
@@ -127,8 +127,8 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
|
||||
const guards = new PluginGuards(dbs, permissions, addons);
|
||||
|
||||
const registry = createTestPluginRegistry([
|
||||
new TagsRpc(new TagsService(dbs)),
|
||||
new CategoriesRpc(new CategoriesService(dbs)),
|
||||
new TagsRpc(new TagsService(await createTestTagsRepo(dbs.connection))),
|
||||
new CategoriesRpc(new CategoriesService(await createTestCategoriesRepo(dbs.connection))),
|
||||
new WeatherRpc(new WeatherService()),
|
||||
new ExchangeRatesRpc(exchangeRates),
|
||||
new TodoRpc(todos, realtime, guards),
|
||||
@@ -170,7 +170,7 @@ export async function createPluginRuntime(dbs: DatabaseService, registry?: Plugi
|
||||
return new PluginRuntimeService(
|
||||
dbs,
|
||||
new AuditService(auditOrm.repo(AuditLog) as AuditLogRepository, auditOrm.repo(Users) as UsersRepository),
|
||||
new AddonsService(dbs),
|
||||
await createTestAddonsService(dbs.connection, dbs),
|
||||
new PluginUserSettingsService(dbs),
|
||||
registry,
|
||||
await createPluginRpcHostFactory(dbs),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { DatabaseService } from '../../src/nest/database/database.service';
|
||||
import { sharedTestOrm } from './test-uow';
|
||||
import { Addons } from '../../src/db/entities/Addons.entity';
|
||||
import type { AddonsRepository } from '../../src/db/repositories/Addons.repository';
|
||||
import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity';
|
||||
import type { PhotoProvidersRepository } from '../../src/db/repositories/PhotoProviders.repository';
|
||||
import { PhotoProviderFields } from '../../src/db/entities/PhotoProviderFields.entity';
|
||||
import type { PhotoProviderFieldsRepository } from '../../src/db/repositories/PhotoProviderFields.repository';
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository';
|
||||
|
||||
/**
|
||||
* AddonsService's constructor grew from one `DatabaseService` to four
|
||||
* injected repositories plus `DatabaseService` (Plan 3a Task 4, addons.md —
|
||||
* the `AppSettingsRepository`/`AddonsRepository`/`PhotoProvidersRepository`/
|
||||
* `PhotoProviderFieldsRepository` wiring). Every hand-constructed
|
||||
* `new AddonsService(dbs)` in the test tree needs the same four repositories
|
||||
* a real `@InjectRepository` graph would hand it — this file is that
|
||||
* factory, named `test-addons.ts` per the Task 4 brief (owned by Task 4;
|
||||
* `tests/helpers/test-uow.ts` stays Task 3's file to extend).
|
||||
*
|
||||
* Built on the shared per-handle ORM (`test-uow.ts`'s `sharedTestOrm`, not a
|
||||
* fresh `createTestOrm` here) — the SAME one `createTestAppSettingsRepo`/
|
||||
* `createTestUnitOfWork` draw from, so a repository built here and a
|
||||
* `UnitOfWork`/repository built elsewhere for the same handle resolve
|
||||
* through the identical context-resolving `EntityManager`, exactly like
|
||||
* Nest's real DI graph (task-2-review.md I2's fix, carried forward for this
|
||||
* domain).
|
||||
*/
|
||||
export async function createTestAddonsRepo(db: Database.Database): Promise<AddonsRepository> {
|
||||
const t = await sharedTestOrm(db);
|
||||
return t.repo(Addons) as AddonsRepository;
|
||||
}
|
||||
|
||||
export async function createTestPhotoProvidersRepo(db: Database.Database): Promise<PhotoProvidersRepository> {
|
||||
const t = await sharedTestOrm(db);
|
||||
return t.repo(PhotoProviders) as PhotoProvidersRepository;
|
||||
}
|
||||
|
||||
export async function createTestPhotoProviderFieldsRepo(db: Database.Database): Promise<PhotoProviderFieldsRepository> {
|
||||
const t = await sharedTestOrm(db);
|
||||
return t.repo(PhotoProviderFields) as PhotoProviderFieldsRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full `AddonsService` a hand-constructed test collaborator needs, bound
|
||||
* to the suite's own better-sqlite3 handle.
|
||||
*
|
||||
* `dbs` defaults to a fresh `DatabaseService` over the same handle when the
|
||||
* caller has no existing one to pass through — AddonsService still needs one
|
||||
* (see `addons.service.ts`'s constructor doc) purely to forward to
|
||||
* `transit-provider.ts`'s readTransitProvider/writeTransitProvider and
|
||||
* `instance-api-keys.ts`'s resolveApiKey, neither of which is one of this
|
||||
* plan's six domains.
|
||||
*/
|
||||
export async function createTestAddonsService(db: Database.Database, dbs: DatabaseService = new DatabaseService(db)): Promise<AddonsService> {
|
||||
const t = await sharedTestOrm(db);
|
||||
return new AddonsService(
|
||||
t.repo(Addons) as AddonsRepository,
|
||||
t.repo(PhotoProviders) as PhotoProvidersRepository,
|
||||
t.repo(PhotoProviderFields) as PhotoProviderFieldsRepository,
|
||||
t.repo(AppSettings) as AppSettingsRepository,
|
||||
dbs,
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import type { McpDynamicTool } from '../../src/nest-mcp';
|
||||
import { getMcpSafeUrl } from '../../src/app-config';
|
||||
import { OauthService } from '../../src/nest/oauth/oauth.service';
|
||||
import { DatabaseService } from '../../src/nest/database/database.service';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../helpers/test-addons';
|
||||
import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
@@ -76,7 +76,7 @@ beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
t = await createTestOrm(testDb);
|
||||
oauthSvc = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository));
|
||||
oauthSvc = new OauthService(oauthDbs, await createTestAddonsService(testDb, oauthDbs), new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -42,7 +42,7 @@ import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { OauthService } from '../../src/nest/oauth/oauth.service';
|
||||
import { DatabaseService } from '../../src/nest/database/database.service';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../helpers/test-addons';
|
||||
import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
@@ -87,7 +87,7 @@ beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
t = await createTestOrm(testDb);
|
||||
containerSideOauth = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository));
|
||||
containerSideOauth = new OauthService(oauthDbs, await createTestAddonsService(testDb, oauthDbs), new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -39,7 +39,7 @@ import { authCookie } from '../helpers/auth';
|
||||
import { ALL_SCOPES } from '../../src/mcp/scopes';
|
||||
import { OauthService } from '../../src/nest/oauth/oauth.service';
|
||||
import { DatabaseService } from '../../src/nest/database/database.service';
|
||||
import { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../helpers/test-addons';
|
||||
import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
@@ -85,7 +85,7 @@ beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
t = await createTestOrm(testDb);
|
||||
containerSideOauth = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository));
|
||||
containerSideOauth = new OauthService(oauthDbs, await createTestAddonsService(testDb, oauthDbs), new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -52,7 +52,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser:
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
|
||||
import { PluginRpcHostFactory } from '../../../src/nest/plugins/host/plugin-rpc-host.factory';
|
||||
@@ -114,6 +114,7 @@ describe('plugin boot vs registry scan ordering', () => {
|
||||
t = await createTestOrm(dbConn);
|
||||
const auditLogRepo = t.repo(AuditLog) as AuditLogRepository;
|
||||
const usersRepo = t.repo(Users) as UsersRepository;
|
||||
const addonsService = await createTestAddonsService(dbConn, dbs);
|
||||
|
||||
mod = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -122,7 +123,7 @@ describe('plugin boot vs registry scan ordering', () => {
|
||||
// (if it had one) first. The boot reconcile must therefore not live there.
|
||||
{
|
||||
provide: PluginRuntimeService,
|
||||
useFactory: () => new PluginRuntimeService(dbs, new AuditService(auditLogRepo, usersRepo), new AddonsService(dbs), userSettings, undefined, hostFactory),
|
||||
useFactory: () => new PluginRuntimeService(dbs, new AuditService(auditLogRepo, usersRepo), addonsService, userSettings, undefined, hostFactory),
|
||||
},
|
||||
{
|
||||
provide: 'REGISTRY_SCAN',
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { Addons } from '../../../../src/db/entities/Addons.entity';
|
||||
import type { AddonsRepository } from '../../../../src/db/repositories/Addons.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let addons: AddonsRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
addons = t.repo(Addons) as AddonsRepository;
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
// resetTestDb deliberately keeps the seeded addons catalogue (test-db.ts's
|
||||
// KEEP_TABLES) — these tests want a known, empty table so every case
|
||||
// controls its own rows.
|
||||
testDb.exec('DELETE FROM addons');
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function rawRow(id: string): unknown {
|
||||
return testDb.prepare('SELECT * FROM addons WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function insertAddon(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
type?: string;
|
||||
icon?: string | null;
|
||||
enabled: 0 | 1;
|
||||
sort_order?: number;
|
||||
}): void {
|
||||
testDb
|
||||
.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(row.id, row.name, row.description ?? null, row.type ?? 'global', row.icon ?? null, row.enabled, row.sort_order ?? 0);
|
||||
}
|
||||
|
||||
describe('AddonsRepository', () => {
|
||||
describe('isEnabled', () => {
|
||||
it('ADDONSREPO-001: reads a stored 1 as true', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Budget', enabled: 1 });
|
||||
expect(await addons.isEnabled('budget')).toBe(true);
|
||||
});
|
||||
|
||||
it('ADDONSREPO-002: reads a stored 0 as false', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Budget', enabled: 0 });
|
||||
expect(await addons.isEnabled('budget')).toBe(false);
|
||||
});
|
||||
|
||||
it('ADDONSREPO-003: a missing row reads false', async () => {
|
||||
expect(await addons.isEnabled('does-not-exist')).toBe(false);
|
||||
});
|
||||
|
||||
// I1 (Task 0 review, carried forward): a primary-key findOne answers a
|
||||
// repeat call from the identity map unless refresh: true is set —
|
||||
// invisible to a write on the same id in the same request.
|
||||
describe('sees a write on the same id in the same request (I1, identity-map regression)', () => {
|
||||
it('ADDONSREPO-004: setEnabled then isEnabled reads the new value, not the stale one', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Budget', enabled: 0 });
|
||||
expect(await addons.isEnabled('budget')).toBe(false); // populate the identity map
|
||||
await addons.setEnabled('budget', true);
|
||||
expect(await addons.isEnabled('budget')).toBe(true);
|
||||
});
|
||||
|
||||
it('ADDONSREPO-005: a raw UPDATE on the same id then isEnabled reads the new value', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Budget', enabled: 0 });
|
||||
expect(await addons.isEnabled('budget')).toBe(false); // populate the identity map
|
||||
testDb.prepare('UPDATE addons SET enabled = 1 WHERE id = ?').run('budget');
|
||||
expect(await addons.isEnabled('budget')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listEnabled', () => {
|
||||
it('ADDONSREPO-006: returns only enabled addons, ordered by sort_order', async () => {
|
||||
insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1, sort_order: 2 });
|
||||
insertAddon({ id: 'budget', name: 'Costs', type: 'trip', icon: 'wallet', enabled: 1, sort_order: 1 });
|
||||
insertAddon({ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0, sort_order: 0 });
|
||||
|
||||
const rows = await addons.listEnabled();
|
||||
expect(rows.map((r) => r.id)).toEqual(['budget', 'atlas']);
|
||||
});
|
||||
|
||||
it('ADDONSREPO-007: the enabled column comes back as a JS boolean, not the stored int', async () => {
|
||||
insertAddon({ id: 'atlas', name: 'Atlas', enabled: 1 });
|
||||
const [row] = await addons.listEnabled();
|
||||
expect(row.enabled).toBe(true);
|
||||
expect(rawRow('atlas')).toMatchObject({ enabled: 1 });
|
||||
});
|
||||
|
||||
it('ADDONSREPO-008: an empty table returns an empty array', async () => {
|
||||
expect(await addons.listEnabled()).toEqual([]);
|
||||
});
|
||||
|
||||
it('ADDONSREPO-009: carries the full row shape (id, name, description, type, icon, enabled, config, sort_order)', async () => {
|
||||
insertAddon({ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'page', icon: 'globe', enabled: 1, sort_order: 3 });
|
||||
const [row] = await addons.listEnabled();
|
||||
expect(row).toEqual({
|
||||
id: 'atlas',
|
||||
name: 'Atlas',
|
||||
description: 'Visited countries map',
|
||||
type: 'page',
|
||||
icon: 'globe',
|
||||
enabled: true,
|
||||
config: {},
|
||||
sort_order: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('ADDONSREPO-010: stores the boolean as the legacy 0/1', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Budget', enabled: 0 });
|
||||
await addons.setEnabled('budget', true);
|
||||
expect(rawRow('budget')).toMatchObject({ enabled: 1 });
|
||||
|
||||
await addons.setEnabled('budget', false);
|
||||
expect(rawRow('budget')).toMatchObject({ enabled: 0 });
|
||||
});
|
||||
|
||||
it('ADDONSREPO-011: does not touch any other column', async () => {
|
||||
insertAddon({ id: 'budget', name: 'Costs', description: 'Track spend', type: 'trip', icon: 'wallet', enabled: 0, sort_order: 5 });
|
||||
await addons.setEnabled('budget', true);
|
||||
expect(rawRow('budget')).toEqual({
|
||||
id: 'budget',
|
||||
name: 'Costs',
|
||||
description: 'Track spend',
|
||||
type: 'trip',
|
||||
icon: 'wallet',
|
||||
enabled: 1,
|
||||
config: '{}',
|
||||
sort_order: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { PhotoProviderFields } from '../../../../src/db/entities/PhotoProviderFields.entity';
|
||||
import type { PhotoProviderFieldsRepository } from '../../../../src/db/repositories/PhotoProviderFields.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let fields: PhotoProviderFieldsRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
fields = t.repo(PhotoProviderFields) as PhotoProviderFieldsRepository;
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
testDb.exec('DELETE FROM photo_provider_fields');
|
||||
testDb.exec('DELETE FROM photo_providers');
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function insertProvider(id: string): void {
|
||||
testDb.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES (?, ?, 'Image', 1, 0)").run(id, id);
|
||||
}
|
||||
|
||||
function insertField(row: {
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type?: string;
|
||||
placeholder?: string | null;
|
||||
hint?: string | null;
|
||||
required?: 0 | 1;
|
||||
secret?: 0 | 1;
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order?: number;
|
||||
}): void {
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO photo_provider_fields
|
||||
(provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
row.provider_id,
|
||||
row.field_key,
|
||||
row.label,
|
||||
row.input_type ?? 'text',
|
||||
row.placeholder ?? null,
|
||||
row.hint ?? null,
|
||||
row.required ?? 0,
|
||||
row.secret ?? 0,
|
||||
row.settings_key ?? null,
|
||||
row.payload_key ?? null,
|
||||
row.sort_order ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
describe('PhotoProviderFieldsRepository.listAllOrdered', () => {
|
||||
it('ADDONSPPFREPO-001: returns every field, ordered by sort_order then id, across all providers', async () => {
|
||||
insertProvider('immich');
|
||||
insertProvider('synology');
|
||||
insertField({ provider_id: 'synology', field_key: 'url', label: 'URL', sort_order: 1 });
|
||||
insertField({ provider_id: 'immich', field_key: 'url', label: 'URL', sort_order: 0 });
|
||||
insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', sort_order: 0 });
|
||||
|
||||
const rows = await fields.listAllOrdered();
|
||||
// Two rows share sort_order 0 — insertion order (ascending id) breaks the tie.
|
||||
expect(rows.map((r) => [r.provider_id, r.field_key])).toEqual([
|
||||
['immich', 'url'],
|
||||
['immich', 'token'],
|
||||
['synology', 'url'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ADDONSPPFREPO-002: the provider_id twin reads back from find(), same as trip_id on Days', async () => {
|
||||
insertProvider('immich');
|
||||
insertField({ provider_id: 'immich', field_key: 'url', label: 'URL' });
|
||||
const [row] = await fields.listAllOrdered();
|
||||
expect(row.provider_id).toBe('immich');
|
||||
});
|
||||
|
||||
it('ADDONSPPFREPO-003: required/secret come back as the raw stored integer, matching the entity type (not a coerced boolean)', async () => {
|
||||
insertProvider('immich');
|
||||
insertField({ provider_id: 'immich', field_key: 'token', label: 'Token', required: 1, secret: 1 });
|
||||
const [row] = await fields.listAllOrdered();
|
||||
expect(row.required).toBe(1);
|
||||
expect(row.secret).toBe(1);
|
||||
expect(typeof row.required).toBe('number');
|
||||
});
|
||||
|
||||
it('ADDONSPPFREPO-004: nullable columns (placeholder, hint, settings_key, payload_key) come back null when unset', async () => {
|
||||
insertProvider('immich');
|
||||
insertField({ provider_id: 'immich', field_key: 'k', label: 'L' });
|
||||
const [row] = await fields.listAllOrdered();
|
||||
expect(row.placeholder).toBeNull();
|
||||
expect(row.hint).toBeNull();
|
||||
expect(row.settings_key).toBeNull();
|
||||
expect(row.payload_key).toBeNull();
|
||||
});
|
||||
|
||||
it('ADDONSPPFREPO-005: an empty table returns an empty array', async () => {
|
||||
expect(await fields.listAllOrdered()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { PhotoProviders } from '../../../../src/db/entities/PhotoProviders.entity';
|
||||
import type { PhotoProvidersRepository } from '../../../../src/db/repositories/PhotoProviders.repository';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let photoProviders: PhotoProvidersRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
photoProviders = t.repo(PhotoProviders) as PhotoProvidersRepository;
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
// photo_provider_fields FK-references photo_providers, so clear the child
|
||||
// first — resetTestDb keeps both tables (test-db.ts's KEEP_TABLES).
|
||||
testDb.exec('DELETE FROM photo_provider_fields');
|
||||
testDb.exec('DELETE FROM photo_providers');
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
function rawRow(id: string): unknown {
|
||||
return testDb.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function insertProvider(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
enabled: 0 | 1;
|
||||
sort_order?: number;
|
||||
}): void {
|
||||
testDb
|
||||
.prepare('INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(row.id, row.name, row.description ?? null, row.icon ?? null, row.enabled, row.sort_order ?? 0);
|
||||
}
|
||||
|
||||
describe('PhotoProvidersRepository.listEnabled', () => {
|
||||
it('ADDONSPPREPO-001: returns only enabled providers, ordered by sort_order then id', async () => {
|
||||
insertProvider({ id: 'synology', name: 'Synology', enabled: 1, sort_order: 0 });
|
||||
insertProvider({ id: 'immich', name: 'Immich', enabled: 1, sort_order: 0 });
|
||||
insertProvider({ id: 'off', name: 'Off', enabled: 0, sort_order: -1 });
|
||||
|
||||
const rows = await photoProviders.listEnabled();
|
||||
// Same sort_order (0) for both enabled rows — id breaks the tie.
|
||||
expect(rows.map((r) => r.id)).toEqual(['immich', 'synology']);
|
||||
});
|
||||
|
||||
it('ADDONSPPREPO-002: the enabled column comes back as the raw stored integer, not a coerced boolean', async () => {
|
||||
insertProvider({ id: 'immich', name: 'Immich', enabled: 1 });
|
||||
const [row] = await photoProviders.listEnabled();
|
||||
expect(row.enabled).toBe(1);
|
||||
expect(typeof row.enabled).toBe('number');
|
||||
});
|
||||
|
||||
it('ADDONSPPREPO-003: an empty table returns an empty array', async () => {
|
||||
expect(await photoProviders.listEnabled()).toEqual([]);
|
||||
});
|
||||
|
||||
it('ADDONSPPREPO-004: carries the full row shape (id, name, description, icon, enabled, sort_order)', async () => {
|
||||
insertProvider({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', icon: 'image', enabled: 1, sort_order: 4 });
|
||||
const [row] = await photoProviders.listEnabled();
|
||||
expect(row).toEqual({
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
description: 'Self-hosted photos',
|
||||
icon: 'image',
|
||||
enabled: 1,
|
||||
sort_order: 4,
|
||||
});
|
||||
expect(rawRow('immich')).toMatchObject({ enabled: 1 });
|
||||
});
|
||||
});
|
||||
@@ -59,8 +59,7 @@ import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
|
||||
const SECTIONS = [
|
||||
@@ -360,7 +359,7 @@ describe('Tool: list_addons', () => {
|
||||
// Written through the same service the admin panel writes through rather
|
||||
// than through a hand-rolled app_settings row, so the tool is checked
|
||||
// against the real writer and not against a restatement of it.
|
||||
const addonsService = new AddonsService(new DatabaseService(testDb));
|
||||
const addonsService = await createTestAddonsService(testDb);
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
await addonsService.updateCollabFeatures({ polls: false });
|
||||
|
||||
@@ -1,27 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Three distinct prepare(...).all() reads (addons, photo_providers, photo_provider_fields).
|
||||
// A single shared statement is reused, so .all() is fed result sets in call order.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
// Takes the statement text, as the real one does: a reader that inlines its
|
||||
// key in the SQL is identified by that string and by nothing else.
|
||||
return { dbMock: { prepare: vi.fn((_sql?: string) => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
/**
|
||||
* Unit tests for AddonsService — rebuilt on real rows (Plan 3a Task 4).
|
||||
*
|
||||
* The legacy version of this file fully stubbed DatabaseService at the
|
||||
* `db.prepare(sql).{get,all,run}` level: a single shared fake `stmt` fed
|
||||
* canned rows *in call order* (three `.all()` reads for addons/providers/
|
||||
* fields, a `.get()` for the journey gate, and so on). That coupling cannot
|
||||
* survive AddonsService now calling four different repositories instead of
|
||||
* one shared prepared statement, so every case here runs against a real
|
||||
* in-memory SQLite DB (createTestOrm) and inserts the exact rows it needs.
|
||||
*
|
||||
* Two cases needed their INTENT re-expressed, not just their mechanics
|
||||
* (noted at each): the legacy `list()` SQL filters `WHERE enabled = 1` on
|
||||
* both `addons` and `photo_providers`, so a disabled row is never returned by
|
||||
* the real query — the old mocked tests fed a disabled row through anyway
|
||||
* (mocks don't enforce a WHERE clause) to prove the service's `!!enabled`
|
||||
* coercion handled `0`. That coercion is still real (and still asserted
|
||||
* directly, on `isAddonEnabled` and on an enabled row's boolean in `list()`),
|
||||
* but "list() returns a disabled addon/provider with enabled: false" is not a
|
||||
* reachable production behaviour, so those two cases now assert the WHERE
|
||||
* filter itself (the disabled row is excluded) alongside the boolean coercion
|
||||
* of the enabled row that remains.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { PlaceShadowService } from '../../../src/nest/place-shadow/place-shadow.service';
|
||||
import { Addons } from '../../../src/db/entities/Addons.entity';
|
||||
import type { AddonsRepository } from '../../../src/db/repositories/Addons.repository';
|
||||
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
||||
import type { PhotoProvidersRepository } from '../../../src/db/repositories/PhotoProviders.repository';
|
||||
import { PhotoProviderFields } from '../../../src/db/entities/PhotoProviderFields.entity';
|
||||
import type { PhotoProviderFieldsRepository } from '../../../src/db/repositories/PhotoProviderFields.repository';
|
||||
import { AppSettings } from '../../../src/db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
|
||||
const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) }));
|
||||
vi.mock('../../../src/nest/memories/memories.helpers', () => ({ getPhotoProviderConfig }));
|
||||
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { PlaceShadowService } from '../../../src/nest/place-shadow/place-shadow.service';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
|
||||
function svc() {
|
||||
return new AddonsService(new DatabaseService(dbConn));
|
||||
}
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let addonsRepo: AddonsRepository;
|
||||
let photoProvidersRepo: PhotoProvidersRepository;
|
||||
let photoProviderFieldsRepo: PhotoProviderFieldsRepository;
|
||||
let appSettingsRepo: AppSettingsRepository;
|
||||
let svc: AddonsService;
|
||||
|
||||
type ListAddon = Awaited<ReturnType<AddonsService['list']>>['addons'][number];
|
||||
|
||||
@@ -47,68 +73,143 @@ function providerFields(addon: ListAddon): PhotoProviderField[] {
|
||||
return (addon as ListAddon & { fields: PhotoProviderField[] }).fields;
|
||||
}
|
||||
|
||||
// Feed list()'s reads in call order: addons, providers, fields (.all), then the
|
||||
// collab-features rows (.all) and the bag-tracking row (.get) from app_settings.
|
||||
// The provider query now sits behind an isAddonEnabled(journey) read (.get), fed
|
||||
// first as enabled so every case below sees its providers.
|
||||
function feedReads(
|
||||
addons: unknown[],
|
||||
providers: unknown[],
|
||||
fields: unknown[],
|
||||
collabRows: unknown[] = [],
|
||||
bagRow: { value: string } | undefined = undefined,
|
||||
) {
|
||||
dbMock._stmt.all
|
||||
.mockReturnValueOnce(addons)
|
||||
.mockReturnValueOnce(providers)
|
||||
.mockReturnValueOnce(fields)
|
||||
.mockReturnValueOnce(collabRows);
|
||||
dbMock._stmt.get.mockReturnValueOnce({ enabled: 1 }).mockReturnValueOnce(bagRow);
|
||||
function insertAddon(row: { id: string; name: string; type?: string; icon?: string | null; enabled: 0 | 1; sort_order?: number }): void {
|
||||
testDb
|
||||
.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, NULL, ?, ?, ?, ?)')
|
||||
.run(row.id, row.name, row.type ?? 'global', row.icon ?? null, row.enabled, row.sort_order ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stubs the journey gate `list()` checks before reading photo providers,
|
||||
* WITHOUT inserting a real 'journey' addons row. A real row is what the
|
||||
* migration seeds (`journey_addon.ts`) and is exactly what `isAddonEnabled`
|
||||
* should read in production — but inserting one here would also make it a
|
||||
* genuine, `enabled: true` member of `listEnabled()`'s own result (the same
|
||||
* WHERE-clause reality the file header's two re-expressed cases describe),
|
||||
* which would pollute every one of these provider/field-shape assertions
|
||||
* with an unrelated 'journey' entry. Spying on the one repository call the
|
||||
* gate makes keeps these tests about provider/field shaping, not about
|
||||
* `list()`'s addons array — that array is already covered by the addons-
|
||||
* focused cases above.
|
||||
*/
|
||||
function stubJourneyEnabled(): ReturnType<typeof vi.spyOn> {
|
||||
return vi.spyOn(addonsRepo, 'isEnabled').mockImplementation(async (id: string) => id === 'journey');
|
||||
}
|
||||
|
||||
function insertProvider(row: { id: string; name: string; icon?: string | null; enabled: 0 | 1; sort_order?: number }): void {
|
||||
testDb
|
||||
.prepare('INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, NULL, ?, ?, ?)')
|
||||
.run(row.id, row.name, row.icon ?? null, row.enabled, row.sort_order ?? 0);
|
||||
}
|
||||
|
||||
function insertField(row: {
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type?: string;
|
||||
placeholder?: string | null;
|
||||
hint?: string | null;
|
||||
required?: 0 | 1;
|
||||
secret?: 0 | 1;
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order?: number;
|
||||
}): void {
|
||||
testDb
|
||||
.prepare(
|
||||
`INSERT INTO photo_provider_fields
|
||||
(provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
row.provider_id,
|
||||
row.field_key,
|
||||
row.label,
|
||||
row.input_type ?? 'text',
|
||||
row.placeholder ?? null,
|
||||
row.hint ?? null,
|
||||
row.required ?? 0,
|
||||
row.secret ?? 0,
|
||||
row.settings_key ?? null,
|
||||
row.payload_key ?? null,
|
||||
row.sort_order ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
function setAppSetting(key: string, value: string | null): void {
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, value);
|
||||
}
|
||||
|
||||
function rawAppSetting(key: string): { key: string; value: string | null } | undefined {
|
||||
return testDb.prepare('SELECT key, value FROM app_settings WHERE key = ?').get(key) as
|
||||
| { key: string; value: string | null }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
addonsRepo = t.repo(Addons) as AddonsRepository;
|
||||
photoProvidersRepo = t.repo(PhotoProviders) as PhotoProvidersRepository;
|
||||
photoProviderFieldsRepo = t.repo(PhotoProviderFields) as PhotoProviderFieldsRepository;
|
||||
appSettingsRepo = t.repo(AppSettings) as AppSettingsRepository;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMock._stmt.all.mockReturnValue([]);
|
||||
dbMock._stmt.get.mockReturnValue(undefined);
|
||||
resetTestDb(testDb);
|
||||
// resetTestDb keeps the seeded addons/photo_providers/photo_provider_fields
|
||||
// catalogue (test-db.ts's KEEP_TABLES) — every case here wants a known,
|
||||
// empty starting point and inserts exactly the rows it needs.
|
||||
testDb.exec('DELETE FROM photo_provider_fields');
|
||||
testDb.exec('DELETE FROM photo_providers');
|
||||
testDb.exec('DELETE FROM addons');
|
||||
t.clear();
|
||||
getPhotoProviderConfig.mockReset();
|
||||
getPhotoProviderConfig.mockReturnValue({});
|
||||
svc = new AddonsService(addonsRepo, photoProvidersRepo, photoProviderFieldsRepo, appSettingsRepo, new DatabaseService(testDb));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restores stubJourneyEnabled's spy on addonsRepo.isEnabled (and the "drops
|
||||
// the photo providers" case's spy on photoProvidersRepo.listEnabled) so no
|
||||
// stub outlives the test that set it up.
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('AddonsService.list', () => {
|
||||
it('returns the collab features and the bag-tracking flag from app_settings', async () => {
|
||||
feedReads([], [], [], [{ key: 'collab_chat_enabled', value: 'false' }], { value: 'true' });
|
||||
setAppSetting('collab_chat_enabled', 'false');
|
||||
setAppSetting('bag_tracking_enabled', 'true');
|
||||
|
||||
const res = await svc().list();
|
||||
const res = await svc.list();
|
||||
expect(res.collabFeatures).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true });
|
||||
expect(res.bagTracking).toBe(true);
|
||||
expect(res.addons).toEqual([]);
|
||||
});
|
||||
|
||||
it('coerces the addon enabled column to a boolean (both 1 and 0)', async () => {
|
||||
feedReads(
|
||||
[
|
||||
{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 },
|
||||
{ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
// Re-expressed (see file header): the legacy WHERE enabled = 1 makes a
|
||||
// disabled row in list()'s output unreachable, so this now proves the
|
||||
// filter (the disabled row never surfaces) and the boolean coercion of the
|
||||
// enabled row that does (the entity's `enabled` is p.boolean(), so the
|
||||
// stored int comes back as a real JS boolean, not just truthy).
|
||||
it('only returns enabled addons, with the stored int coerced to a JS boolean', async () => {
|
||||
insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 });
|
||||
insertAddon({ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 });
|
||||
|
||||
const res = await svc().list();
|
||||
expect(res.addons).toEqual([
|
||||
{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true },
|
||||
{ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: false },
|
||||
]);
|
||||
const res = await svc.list();
|
||||
expect(res.addons).toEqual([{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }]);
|
||||
});
|
||||
|
||||
it('maps a photo provider with no fields to an empty fields array (the || [] fallback)', async () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[],
|
||||
);
|
||||
stubJourneyEnabled();
|
||||
insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
getPhotoProviderConfig.mockReturnValue({ baseUrl: 'http://x' });
|
||||
|
||||
const res = await svc().list();
|
||||
const res = await svc.list();
|
||||
expect(res.addons).toEqual([
|
||||
{
|
||||
id: 'immich',
|
||||
@@ -123,53 +224,49 @@ describe('AddonsService.list', () => {
|
||||
expect(getPhotoProviderConfig).toHaveBeenCalledWith('immich');
|
||||
});
|
||||
|
||||
it('coerces a disabled photo provider enabled flag to false', async () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'synology', name: 'Synology', icon: 'image', enabled: 0, sort_order: 1 }],
|
||||
[],
|
||||
);
|
||||
// Re-expressed for the same reason as the addons case above: a disabled
|
||||
// photo_providers row is filtered out by listEnabled()'s WHERE clause, so
|
||||
// this now proves that filter alongside the enabled row's integer→boolean
|
||||
// coercion (photo_providers.enabled is p.integer(), not p.boolean() —
|
||||
// AddonsService does the `!!` itself, unlike the Addons entity above).
|
||||
it('only returns enabled photo providers, with the stored integer coerced to a JS boolean', async () => {
|
||||
stubJourneyEnabled();
|
||||
insertProvider({ id: 'synology', name: 'Synology', icon: 'image', enabled: 1, sort_order: 1 });
|
||||
insertProvider({ id: 'off-provider', name: 'Off', icon: 'image', enabled: 0, sort_order: 2 });
|
||||
|
||||
const res = await svc().list();
|
||||
expect((res.addons[0] as { enabled: boolean }).enabled).toBe(false);
|
||||
const res = await svc.list();
|
||||
expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['synology']);
|
||||
expect((res.addons[0] as { enabled: boolean }).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('groups multiple fields under their provider and keeps insertion order', async () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'immich',
|
||||
field_key: 'url',
|
||||
label: 'URL',
|
||||
input_type: 'text',
|
||||
placeholder: 'https://',
|
||||
hint: 'Base URL',
|
||||
required: 1,
|
||||
secret: 0,
|
||||
settings_key: 'immich_url',
|
||||
payload_key: 'url',
|
||||
sort_order: 0,
|
||||
},
|
||||
// Second field for the SAME provider exercises the `get(...) || []` truthy branch.
|
||||
{
|
||||
provider_id: 'immich',
|
||||
field_key: 'token',
|
||||
label: 'Token',
|
||||
input_type: 'password',
|
||||
placeholder: null,
|
||||
hint: null,
|
||||
required: 0,
|
||||
secret: 1,
|
||||
settings_key: null,
|
||||
payload_key: null,
|
||||
sort_order: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
stubJourneyEnabled();
|
||||
insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
insertField({
|
||||
provider_id: 'immich',
|
||||
field_key: 'url',
|
||||
label: 'URL',
|
||||
input_type: 'text',
|
||||
placeholder: 'https://',
|
||||
hint: 'Base URL',
|
||||
required: 1,
|
||||
secret: 0,
|
||||
settings_key: 'immich_url',
|
||||
payload_key: 'url',
|
||||
sort_order: 0,
|
||||
});
|
||||
// Second field for the SAME provider exercises the `get(...) || []` truthy branch.
|
||||
insertField({
|
||||
provider_id: 'immich',
|
||||
field_key: 'token',
|
||||
label: 'Token',
|
||||
input_type: 'password',
|
||||
required: 0,
|
||||
secret: 1,
|
||||
sort_order: 1,
|
||||
});
|
||||
|
||||
const res = await svc().list();
|
||||
const res = await svc.list();
|
||||
expect(providerFields(res.addons[0])).toEqual([
|
||||
{
|
||||
key: 'url',
|
||||
@@ -199,24 +296,11 @@ describe('AddonsService.list', () => {
|
||||
});
|
||||
|
||||
it('falls back placeholder→"", hint→null, settings/payload keys→null when columns are missing/empty', async () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'p', name: 'P', icon: 'i', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'p',
|
||||
field_key: 'k',
|
||||
label: 'L',
|
||||
input_type: 'text',
|
||||
// placeholder/hint/settings_key/payload_key omitted entirely (undefined)
|
||||
required: 0,
|
||||
secret: 0,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
);
|
||||
stubJourneyEnabled();
|
||||
insertProvider({ id: 'p', name: 'P', icon: 'i', enabled: 1 });
|
||||
insertField({ provider_id: 'p', field_key: 'k', label: 'L' }); // placeholder/hint/settings_key/payload_key all null
|
||||
|
||||
const res = await svc().list();
|
||||
const res = await svc.list();
|
||||
const field = providerFields(res.addons[0])[0];
|
||||
expect(field).toMatchObject({
|
||||
placeholder: '',
|
||||
@@ -227,53 +311,38 @@ describe('AddonsService.list', () => {
|
||||
});
|
||||
|
||||
it('keeps fields belonging to other providers out of a provider with none of its own', async () => {
|
||||
stubJourneyEnabled();
|
||||
// A field exists, but for a DIFFERENT provider than the one returned — exercises
|
||||
// the `fieldsByProvider.get(p.id) || []` fallback while the map is non-empty.
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'has-none', name: 'X', icon: 'i', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'other',
|
||||
field_key: 'k',
|
||||
label: 'L',
|
||||
input_type: 'text',
|
||||
required: 0,
|
||||
secret: 0,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
);
|
||||
insertProvider({ id: 'has-none', name: 'X', icon: 'i', enabled: 1 });
|
||||
insertProvider({ id: 'other', name: 'Other', icon: 'i', enabled: 0 }); // FK target for the field below
|
||||
insertField({ provider_id: 'other', field_key: 'k', label: 'L' });
|
||||
|
||||
const res = await svc().list();
|
||||
const res = await svc.list();
|
||||
expect(providerFields(res.addons[0])).toEqual([]);
|
||||
});
|
||||
|
||||
it('concatenates regular addons before the photo providers', async () => {
|
||||
feedReads(
|
||||
[{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[],
|
||||
);
|
||||
stubJourneyEnabled();
|
||||
insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 });
|
||||
insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
|
||||
const res = await svc().list();
|
||||
const res = await svc.list();
|
||||
expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['atlas', 'immich']);
|
||||
expect((res.addons[1] as { type: string }).type).toBe('photo_provider');
|
||||
});
|
||||
|
||||
it('drops the photo providers while the journey addon is off, whatever their rows say', async () => {
|
||||
// The journey gate (.get) answers disabled, so the provider query is never
|
||||
// made and the .all chain shortens to addons, fields, collab.
|
||||
dbMock._stmt.get.mockReturnValueOnce({ enabled: 0 });
|
||||
dbMock._stmt.all
|
||||
.mockReturnValueOnce([{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }])
|
||||
.mockReturnValueOnce([])
|
||||
.mockReturnValueOnce([]);
|
||||
// No 'journey' row at all — isAddonEnabled('journey') reads false, same as
|
||||
// the legacy `!!undefined`.
|
||||
insertAddon({ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 });
|
||||
insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
const listSpy = vi.spyOn(photoProvidersRepo, 'listEnabled');
|
||||
|
||||
const res = await svc().list();
|
||||
const res = await svc.list();
|
||||
expect(res.addons).toEqual([{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }]);
|
||||
const providerReads = (dbMock.prepare.mock.calls as unknown[][]).filter((call) => String(call[0]).includes('FROM photo_providers'));
|
||||
expect(providerReads).toEqual([]);
|
||||
expect(listSpy).not.toHaveBeenCalled();
|
||||
listSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,62 +351,65 @@ describe('AddonsService.list', () => {
|
||||
// purpose: bag tracking is opt-in (=== 'true'), collab flags opt-out (!== 'false').
|
||||
describe('AddonsService addon/feature flags', () => {
|
||||
it('isAddonEnabled coerces the enabled column (1/0/missing row)', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ enabled: 1 });
|
||||
expect(await svc().isAddonEnabled('budget')).toBe(true);
|
||||
dbMock._stmt.get.mockReturnValueOnce({ enabled: 0 });
|
||||
expect(await svc().isAddonEnabled('budget')).toBe(false);
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined);
|
||||
expect(await svc().isAddonEnabled('nope')).toBe(false);
|
||||
insertAddon({ id: 'budget', name: 'Budget', enabled: 1 });
|
||||
expect(await svc.isAddonEnabled('budget')).toBe(true);
|
||||
|
||||
testDb.prepare('UPDATE addons SET enabled = 0 WHERE id = ?').run('budget');
|
||||
expect(await svc.isAddonEnabled('budget')).toBe(false);
|
||||
|
||||
expect(await svc.isAddonEnabled('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('getBagTracking is opt-in: only the literal string true enables it', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'true' });
|
||||
expect(await svc().getBagTracking()).toEqual({ enabled: true });
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'false' });
|
||||
expect(await svc().getBagTracking()).toEqual({ enabled: false });
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined); // absent row → OFF
|
||||
expect(await svc().getBagTracking()).toEqual({ enabled: false });
|
||||
setAppSetting('bag_tracking_enabled', 'true');
|
||||
expect(await svc.getBagTracking()).toEqual({ enabled: true });
|
||||
|
||||
setAppSetting('bag_tracking_enabled', 'false');
|
||||
expect(await svc.getBagTracking()).toEqual({ enabled: false });
|
||||
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'bag_tracking_enabled'").run();
|
||||
expect(await svc.getBagTracking()).toEqual({ enabled: false }); // absent row → OFF
|
||||
});
|
||||
|
||||
it('updateBagTracking persists true/false strings and echoes the flag (ADMIN-SVC-030)', async () => {
|
||||
expect(await svc().updateBagTracking(true)).toEqual({ enabled: true });
|
||||
expect(dbMock._stmt.run).toHaveBeenCalledWith('true');
|
||||
expect(await svc().updateBagTracking(false)).toEqual({ enabled: false });
|
||||
expect(dbMock._stmt.run).toHaveBeenCalledWith('false');
|
||||
expect(await svc.updateBagTracking(true)).toEqual({ enabled: true });
|
||||
expect(rawAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'true' });
|
||||
|
||||
expect(await svc.updateBagTracking(false)).toEqual({ enabled: false });
|
||||
expect(rawAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'false' });
|
||||
});
|
||||
|
||||
it('getCollabFeatures is opt-out: absent rows default ON, only false disables', async () => {
|
||||
dbMock._stmt.all.mockReturnValueOnce([
|
||||
{ key: 'collab_chat_enabled', value: 'false' },
|
||||
{ key: 'collab_polls_enabled', value: 'true' },
|
||||
]);
|
||||
expect(await svc().getCollabFeatures()).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true });
|
||||
setAppSetting('collab_chat_enabled', 'false');
|
||||
setAppSetting('collab_polls_enabled', 'true');
|
||||
|
||||
expect(await svc.getCollabFeatures()).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true });
|
||||
});
|
||||
|
||||
it('updateCollabFeatures writes only the provided flags and reports changed (#1414, ADMIN-SVC-070)', async () => {
|
||||
const setValueSpy = vi.spyOn(appSettingsRepo, 'setValue');
|
||||
|
||||
// before-read: all default ON; after-read: chat flipped off → changed
|
||||
dbMock._stmt.all.mockReturnValueOnce([]).mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]);
|
||||
const first = await svc().updateCollabFeatures({ chat: false });
|
||||
const first = await svc.updateCollabFeatures({ chat: false });
|
||||
expect(first.changed).toBe(true);
|
||||
expect(first.features.chat).toBe(false);
|
||||
expect(dbMock._stmt.run).toHaveBeenCalledTimes(1);
|
||||
expect(dbMock._stmt.run).toHaveBeenCalledWith('collab_chat_enabled', 'false');
|
||||
expect(setValueSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setValueSpy).toHaveBeenCalledWith('collab_chat_enabled', 'false');
|
||||
expect(rawAppSetting('collab_chat_enabled')).toEqual({ key: 'collab_chat_enabled', value: 'false' });
|
||||
|
||||
// identical save → before and after read the same → no change, MCP sessions must survive
|
||||
dbMock._stmt.run.mockClear();
|
||||
dbMock._stmt.all
|
||||
.mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }])
|
||||
.mockReturnValueOnce([{ key: 'collab_chat_enabled', value: 'false' }]);
|
||||
const second = await svc().updateCollabFeatures({ chat: false });
|
||||
setValueSpy.mockClear();
|
||||
const second = await svc.updateCollabFeatures({ chat: false });
|
||||
expect(second.changed).toBe(false);
|
||||
expect(dbMock._stmt.run).toHaveBeenCalledWith('collab_chat_enabled', 'false');
|
||||
expect(setValueSpy).toHaveBeenCalledWith('collab_chat_enabled', 'false');
|
||||
|
||||
// undefined flags are not written
|
||||
dbMock._stmt.run.mockClear();
|
||||
dbMock._stmt.all.mockReturnValueOnce([]).mockReturnValueOnce([]);
|
||||
const third = await svc().updateCollabFeatures({});
|
||||
setValueSpy.mockClear();
|
||||
const third = await svc.updateCollabFeatures({});
|
||||
expect(third.changed).toBe(false);
|
||||
expect(dbMock._stmt.run).not.toHaveBeenCalled();
|
||||
expect(setValueSpy).not.toHaveBeenCalled();
|
||||
|
||||
setValueSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -349,10 +421,6 @@ describe('AddonsService addon/feature flags', () => {
|
||||
* nobody loses a feature on upgrade.
|
||||
*/
|
||||
describe('AddonsService places flags', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const cases = [
|
||||
['getPlacesPhotos', 'updatePlacesPhotos', 'places_photos_enabled'],
|
||||
['getPlacesAutocomplete', 'updatePlacesAutocomplete', 'places_autocomplete_enabled'],
|
||||
@@ -361,29 +429,28 @@ describe('AddonsService places flags', () => {
|
||||
|
||||
it('ADDONS-SVC-080 an unset flag reads as OFF, and anything but the literal true does too', async () => {
|
||||
for (const [getter, , key] of cases) {
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined);
|
||||
expect(await svc()[getter]()).toEqual({ enabled: false });
|
||||
expect(dbMock.prepare).toHaveBeenLastCalledWith('SELECT value FROM app_settings WHERE key = ?');
|
||||
expect(dbMock._stmt.get).toHaveBeenLastCalledWith(key);
|
||||
testDb.prepare('DELETE FROM app_settings WHERE key = ?').run(key);
|
||||
expect(await svc[getter]()).toEqual({ enabled: false });
|
||||
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'garbage' });
|
||||
expect(await svc()[getter]()).toEqual({ enabled: false });
|
||||
setAppSetting(key, 'garbage');
|
||||
expect(await svc[getter]()).toEqual({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-081 a stored "true" reads as ON', async () => {
|
||||
for (const [getter] of cases) {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'true' });
|
||||
expect(await svc()[getter]()).toEqual({ enabled: true });
|
||||
for (const [getter, , key] of cases) {
|
||||
setAppSetting(key, 'true');
|
||||
expect(await svc[getter]()).toEqual({ enabled: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-082 the setters persist the literal string and echo the boolean back', async () => {
|
||||
for (const [, setter, key] of cases) {
|
||||
expect(await svc()[setter](true)).toEqual({ enabled: true });
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith(key, 'true');
|
||||
expect(await svc()[setter](false)).toEqual({ enabled: false });
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith(key, 'false');
|
||||
expect(await svc[setter](true)).toEqual({ enabled: true });
|
||||
expect(rawAppSetting(key)).toEqual({ key, value: 'true' });
|
||||
|
||||
expect(await svc[setter](false)).toEqual({ enabled: false });
|
||||
expect(rawAppSetting(key)).toEqual({ key, value: 'false' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -399,30 +466,26 @@ describe('AddonsService places flags', () => {
|
||||
* feature runs.
|
||||
*/
|
||||
describe('AddonsService places enrichment flag', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-083 an unset flag reads as ON', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined);
|
||||
expect(await svc().getPlacesEnrich()).toEqual({ enabled: true });
|
||||
expect(await svc.getPlacesEnrich()).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-084 only the literal "false" switches it off', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'false' });
|
||||
expect(await svc().getPlacesEnrich()).toEqual({ enabled: false });
|
||||
setAppSetting('places_enrich_enabled', 'false');
|
||||
expect(await svc.getPlacesEnrich()).toEqual({ enabled: false });
|
||||
|
||||
for (const value of ['true', 'garbage', '']) {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value });
|
||||
expect(await svc().getPlacesEnrich()).toEqual({ enabled: true });
|
||||
setAppSetting('places_enrich_enabled', value);
|
||||
expect(await svc.getPlacesEnrich()).toEqual({ enabled: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-085 the setter persists the literal string and echoes the boolean back', async () => {
|
||||
expect(await svc().updatePlacesEnrich(false)).toEqual({ enabled: false });
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_enrich_enabled', 'false');
|
||||
expect(await svc().updatePlacesEnrich(true)).toEqual({ enabled: true });
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_enrich_enabled', 'true');
|
||||
expect(await svc.updatePlacesEnrich(false)).toEqual({ enabled: false });
|
||||
expect(rawAppSetting('places_enrich_enabled')).toEqual({ key: 'places_enrich_enabled', value: 'false' });
|
||||
|
||||
expect(await svc.updatePlacesEnrich(true)).toEqual({ enabled: true });
|
||||
expect(rawAppSetting('places_enrich_enabled')).toEqual({ key: 'places_enrich_enabled', value: 'true' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -431,34 +494,33 @@ describe('AddonsService places enrichment flag', () => {
|
||||
* unrecognised-value case the booleans get for free: anything that is not a
|
||||
* known provider must read as Transitous rather than silently billing the
|
||||
* install's Google key.
|
||||
*
|
||||
* NOT one of this task's 11 app_settings/addons/photo_provider(_fields) sites
|
||||
* (transit-provider.ts and instance-api-keys.ts are other domains, out of this
|
||||
* plan's six) — still on DatabaseService, exercised here over the same real
|
||||
* connection every other case uses.
|
||||
*/
|
||||
describe('AddonsService transit provider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-086 an unset provider reads as Transitous, with no key anywhere', async () => {
|
||||
dbMock._stmt.get.mockReturnValue(undefined);
|
||||
expect(await svc().getTransitProvider()).toEqual({ provider: 'transitous', googleKeySource: null });
|
||||
expect(await svc.getTransitProvider()).toEqual({ provider: 'transitous', googleKeySource: null });
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-087 only a known provider name is honoured', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'google' }).mockReturnValue(undefined);
|
||||
expect((await svc().getTransitProvider()).provider).toBe('google');
|
||||
setAppSetting('transit_provider', 'google');
|
||||
expect((await svc.getTransitProvider()).provider).toBe('google');
|
||||
|
||||
for (const value of ['someday-maps', '', 'GOOGLE']) {
|
||||
dbMock._stmt.get.mockReset();
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value }).mockReturnValue(undefined);
|
||||
expect((await svc().getTransitProvider()).provider).toBe('transitous');
|
||||
setAppSetting('transit_provider', value);
|
||||
expect((await svc.getTransitProvider()).provider).toBe('transitous');
|
||||
}
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-088 the setter persists the name and echoes it back', async () => {
|
||||
dbMock._stmt.get.mockReturnValue(undefined);
|
||||
expect((await svc().updateTransitProvider('google')).provider).toBe('google');
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('transit_provider', 'google');
|
||||
expect((await svc().updateTransitProvider('transitous')).provider).toBe('transitous');
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('transit_provider', 'transitous');
|
||||
expect((await svc.updateTransitProvider('google')).provider).toBe('google');
|
||||
expect(rawAppSetting('transit_provider')).toEqual({ key: 'transit_provider', value: 'google' });
|
||||
|
||||
expect((await svc.updateTransitProvider('transitous')).provider).toBe('transitous');
|
||||
expect(rawAppSetting('transit_provider')).toEqual({ key: 'transit_provider', value: 'transitous' });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -467,23 +529,19 @@ describe('AddonsService transit provider', () => {
|
||||
* means "works for this admin, Transitous for everybody else".
|
||||
*/
|
||||
it('ADDONS-SVC-089 reports where the Google key resolved from', async () => {
|
||||
// provider row, then the instance maps_api_key row.
|
||||
dbMock._stmt.get.mockReset();
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'google' }).mockReturnValueOnce({ value: 'instance-key' });
|
||||
expect((await svc().getTransitProvider(7)).googleKeySource).toBe('instance');
|
||||
setAppSetting('transit_provider', 'google');
|
||||
setAppSetting('maps_api_key', 'instance-key');
|
||||
expect((await svc.getTransitProvider(7)).googleKeySource).toBe('instance');
|
||||
|
||||
// No instance row, but the caller's own users column has one.
|
||||
dbMock._stmt.get.mockReset();
|
||||
dbMock._stmt.get
|
||||
.mockReturnValueOnce({ value: 'google' })
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce({ maps_api_key: 'personal-key' });
|
||||
expect((await svc().getTransitProvider(7)).googleKeySource).toBe('user-row');
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'maps_api_key'").run();
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', user.id);
|
||||
expect((await svc.getTransitProvider(user.id)).googleKeySource).toBe('user-row');
|
||||
|
||||
// Nothing anywhere.
|
||||
dbMock._stmt.get.mockReset();
|
||||
dbMock._stmt.get.mockReturnValue(undefined);
|
||||
expect((await svc().getTransitProvider(7)).googleKeySource).toBeNull();
|
||||
const { user: bareUser } = createUser(testDb);
|
||||
expect((await svc.getTransitProvider(bareUser.id)).googleKeySource).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -496,76 +554,49 @@ describe('AddonsService transit provider', () => {
|
||||
* diverge the admin panel shows "off" while the log keeps collecting picks.
|
||||
*/
|
||||
describe('AddonsService place shadow flag', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-090 an unset flag reads as OFF, and so does every value but the literal "true"', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined);
|
||||
expect(await svc().getPlaceShadow()).toEqual({ enabled: false });
|
||||
expect(dbMock.prepare).toHaveBeenLastCalledWith('SELECT value FROM app_settings WHERE key = ?');
|
||||
expect(dbMock._stmt.get).toHaveBeenLastCalledWith('place_shadow_enabled');
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: false });
|
||||
|
||||
for (const value of ['false', 'TRUE', '1', '']) {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value });
|
||||
expect(await svc().getPlaceShadow()).toEqual({ enabled: false });
|
||||
setAppSetting('place_shadow_enabled', value);
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-091 a stored "true" reads as ON', async () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ value: 'true' });
|
||||
expect(await svc().getPlaceShadow()).toEqual({ enabled: true });
|
||||
setAppSetting('place_shadow_enabled', 'true');
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-092 the setter round-trips through the getter under its own key', async () => {
|
||||
// Keyed store instead of an echo assertion: the write has to produce the
|
||||
// exact string the read compares against, so a setter persisting '1' fails
|
||||
// here rather than silently reading back OFF in production.
|
||||
const stored = new Map<string, string>();
|
||||
dbMock._stmt.run.mockImplementation((key: string, value: string) => {
|
||||
stored.set(key, value);
|
||||
});
|
||||
dbMock._stmt.get.mockImplementation((key: string) => {
|
||||
const value = stored.get(key);
|
||||
return value === undefined ? undefined : { value };
|
||||
});
|
||||
expect(await svc.updatePlaceShadow(true)).toEqual({ enabled: true });
|
||||
expect(rawAppSetting('place_shadow_enabled')).toEqual({ key: 'place_shadow_enabled', value: 'true' });
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: true });
|
||||
// a sibling switch must not ride along
|
||||
expect(await svc.getPlacesDetails()).toEqual({ enabled: false });
|
||||
|
||||
expect(await svc().updatePlaceShadow(true)).toEqual({ enabled: true });
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('place_shadow_enabled', 'true');
|
||||
expect(await svc().getPlaceShadow()).toEqual({ enabled: true });
|
||||
// a sibling switch must not ride along on the shared statement
|
||||
expect(await svc().getPlacesDetails()).toEqual({ enabled: false });
|
||||
|
||||
expect(await svc().updatePlaceShadow(false)).toEqual({ enabled: false });
|
||||
expect(dbMock._stmt.run).toHaveBeenLastCalledWith('place_shadow_enabled', 'false');
|
||||
expect(await svc().getPlaceShadow()).toEqual({ enabled: false });
|
||||
expect([...stored.keys()]).toEqual(['place_shadow_enabled']);
|
||||
expect(await svc.updatePlaceShadow(false)).toEqual({ enabled: false });
|
||||
expect(rawAppSetting('place_shadow_enabled')).toEqual({ key: 'place_shadow_enabled', value: 'false' });
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: false });
|
||||
// no sibling key was ever written
|
||||
expect(rawAppSetting('places_details_enabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADDONS-SVC-093 answers the same as PlaceShadowService.enabled() for every stored value', async () => {
|
||||
const shadow = new PlaceShadowService(new DatabaseService(dbConn));
|
||||
const rows: Array<[{ value: string } | undefined, boolean]> = [
|
||||
const shadow = new PlaceShadowService(new DatabaseService(testDb));
|
||||
const cases: Array<[string | undefined, boolean]> = [
|
||||
[undefined, false],
|
||||
[{ value: 'true' }, true],
|
||||
[{ value: 'false' }, false],
|
||||
[{ value: 'garbage' }, false],
|
||||
['true', true],
|
||||
['false', false],
|
||||
['garbage', false],
|
||||
];
|
||||
|
||||
for (const [row, expected] of rows) {
|
||||
// one read for the admin getter, one for the service that gates the log
|
||||
dbMock._stmt.get.mockReturnValueOnce(row).mockReturnValueOnce(row);
|
||||
|
||||
// The shared statement hands both readers this row whatever they ask for,
|
||||
// so the key each one names has to be asserted too: the getter binds it,
|
||||
// the gate inlines it, and a divergence there would still look like
|
||||
// agreement on the value alone.
|
||||
expect(await svc().getPlaceShadow()).toEqual({ enabled: expected });
|
||||
expect(dbMock._stmt.get).toHaveBeenLastCalledWith('place_shadow_enabled');
|
||||
for (const [value, expected] of cases) {
|
||||
if (value === undefined) testDb.prepare("DELETE FROM app_settings WHERE key = 'place_shadow_enabled'").run();
|
||||
else setAppSetting('place_shadow_enabled', value);
|
||||
|
||||
expect(await svc.getPlaceShadow()).toEqual({ enabled: expected });
|
||||
expect(await shadow.enabled()).toBe(expected);
|
||||
expect(dbMock.prepare).toHaveBeenLastCalledWith(
|
||||
"SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createUserWithMfa, createAdmin, createInviteToken } from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||||
import { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||||
@@ -104,7 +104,7 @@ beforeAll(async () => {
|
||||
auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection));
|
||||
svc = new AdminService(
|
||||
dbs,
|
||||
new AddonsService(dbs),
|
||||
await createTestAddonsService(testDb, dbs),
|
||||
new PasskeyService(dbs, auth, webauthn, await createTestUnitOfWork(dbs.connection)),
|
||||
auth,
|
||||
permissions,
|
||||
|
||||
@@ -43,7 +43,7 @@ vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(),
|
||||
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { SettingsService } from '../../../src/nest/settings/settings.service';
|
||||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||||
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
||||
@@ -82,7 +82,7 @@ beforeAll(async () => {
|
||||
auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection));
|
||||
svc = new AdminService(
|
||||
dbs,
|
||||
new AddonsService(dbs),
|
||||
await createTestAddonsService(testDb, dbs),
|
||||
new PasskeyService(dbs, auth, webauthn, await createTestUnitOfWork(dbs.connection)),
|
||||
auth,
|
||||
permissions,
|
||||
|
||||
@@ -45,7 +45,8 @@ import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
import { DawarichError } from '../../../src/nest/integrations/dawarich.client';
|
||||
@@ -112,7 +113,7 @@ const dawarich = {
|
||||
// Direct construction over the shared test connection — no TestingModule
|
||||
// (repo convention for DI-native service unit tests).
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const addons = new AddonsService(dbs);
|
||||
let addons: AddonsService;
|
||||
let svc: DawarichSyncService;
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
@@ -226,6 +227,7 @@ function withVisits(...visits: DawarichVisitRaw[]): void {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
addons = await createTestAddonsService(testDb, dbs);
|
||||
svc = new DawarichSyncService(dbs, addons, client, dawarich, await createTestUnitOfWork(dbs.connection));
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { DiscoveryController } from '../../../src/nest/platform/discovery.contro
|
||||
import { PlatformModule } from '../../../src/nest/platform/platform.module';
|
||||
import { ALL_SCOPES } from '../../../src/mcp/scopes';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import type { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
@@ -47,6 +48,19 @@ function addons(enabled: boolean) {
|
||||
return { isAddonEnabled: vi.fn(() => enabled) } as unknown as AddonsService;
|
||||
}
|
||||
|
||||
/**
|
||||
* createMcpMetadataMiddleware wraps the addon check in withRequestContext
|
||||
* (Plan 3a Task 4: this pathless pre-init mount runs before
|
||||
* @mikro-orm/nestjs's per-request EM fork, so once isAddonEnabled became
|
||||
* repository-backed it needed its own context). The mocked AddonsService
|
||||
* above never touches the ORM, so this fake only needs to satisfy
|
||||
* RequestContext.create's em.fork()/em.name contract.
|
||||
*/
|
||||
function fakeOrm(): MikroORM {
|
||||
const em = { name: 'default', fork: () => ({ name: 'default' }) };
|
||||
return { em } as unknown as MikroORM;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
h.getMcpSafeUrl.mockReturnValue('https://trek.example.test');
|
||||
@@ -105,7 +119,7 @@ async function flushMicrotasks(): Promise<void> {
|
||||
|
||||
describe('createMcpMetadataMiddleware', () => {
|
||||
it('DISC-010: 404s a /.well-known path with an empty body when MCP is disabled', async () => {
|
||||
const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), addons(false));
|
||||
const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), addons(false), fakeOrm());
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
mw({ path: '/.well-known/oauth-authorization-server' } as never, res as never, next);
|
||||
@@ -117,7 +131,7 @@ describe('createMcpMetadataMiddleware', () => {
|
||||
});
|
||||
|
||||
it('DISC-011: delegates a /.well-known path to the SDK router when MCP is enabled', async () => {
|
||||
const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), addons(true));
|
||||
const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), addons(true), fakeOrm());
|
||||
const next = vi.fn();
|
||||
mw({ path: '/.well-known/oauth-authorization-server' } as never, makeRes() as never, next);
|
||||
await flushMicrotasks();
|
||||
@@ -126,16 +140,16 @@ describe('createMcpMetadataMiddleware', () => {
|
||||
|
||||
it('DISC-012: delegates non-well-known paths without consulting the addon gate', () => {
|
||||
const gate = addons(false);
|
||||
const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), gate);
|
||||
const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), gate, fakeOrm());
|
||||
mw({ path: '/api/trips' } as never, makeRes() as never, vi.fn());
|
||||
expect(gate.isAddonEnabled).not.toHaveBeenCalled();
|
||||
expect(h.metaRouter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DISC-013: the factory provider wires the token to the factory with its two deps', () => {
|
||||
it('DISC-013: the factory provider wires the token to the factory with its three deps (MikroORM added — Plan 3a Task 4)', () => {
|
||||
expect(mcpMetadataMiddlewareProvider.provide).toBe(MCP_METADATA_MIDDLEWARE);
|
||||
expect(mcpMetadataMiddlewareProvider.useFactory).toBe(createMcpMetadataMiddleware);
|
||||
expect(mcpMetadataMiddlewareProvider.inject.length).toBe(2);
|
||||
expect(mcpMetadataMiddlewareProvider.inject.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { ADDON_IDS } from '../../../src/addons';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { UnifiedMemoriesService } from '../../../src/nest/memories/unified-memories.service';
|
||||
import { MemoriesAccessService } from '../../../src/nest/memories/memories-access.service';
|
||||
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
|
||||
@@ -81,7 +81,7 @@ beforeAll(async () => {
|
||||
{} as SynologyService,
|
||||
new MemoriesAccessService(dbs),
|
||||
notificationsStub(),
|
||||
new AddonsService(dbs),
|
||||
await createTestAddonsService(testDb, dbs),
|
||||
await createTestUnitOfWork(testDb),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.s
|
||||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
|
||||
import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
|
||||
function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
|
||||
testDb.prepare(
|
||||
@@ -174,7 +174,7 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', (
|
||||
// one over the same DB. These fixtures declare no dependencies, so it is never consulted.
|
||||
const listPlugins = async () => {
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
return (await new PluginsService(dbs, new AddonsService(dbs)).list()).plugins;
|
||||
return (await new PluginsService(dbs, await createTestAddonsService(testDb, dbs)).list()).plugins;
|
||||
};
|
||||
|
||||
const before = await listPlugins();
|
||||
|
||||
@@ -30,7 +30,8 @@ import { PluginsController } from '../../../src/nest/plugins/plugins.controller'
|
||||
import { PluginConsentRequired, type PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import type { PluginRegistryService } from '../../../src/nest/plugins/registry/registry.service';
|
||||
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
import { discoverPlugins } from '../../../src/nest/plugins/install/discovery';
|
||||
|
||||
@@ -52,10 +53,8 @@ function declareField(pluginId: string, key: string, scope: 'instance' | 'user',
|
||||
.run(pluginId, key, key, opts.required ? 1 : 0, opts.secret ? 1 : 0, scope, opts.options ?? null, opts.sortOrder ?? 0);
|
||||
}
|
||||
|
||||
const svc = () => {
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
return new PluginsService(dbs, new AddonsService(dbs));
|
||||
};
|
||||
let addonsService: AddonsService;
|
||||
const svc = () => new PluginsService(new DatabaseService(dbConn), addonsService);
|
||||
|
||||
/**
|
||||
* Installs a real plugin through the manifest -> discovery pipeline (not a direct
|
||||
@@ -74,9 +73,10 @@ async function installFixturePlugin(opts: { settings: Array<Record<string, unkno
|
||||
await discoverPlugins(testDb);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
addonsService = await createTestAddonsService(testDb, new DatabaseService(dbConn));
|
||||
});
|
||||
beforeEach(() => {
|
||||
testDb.prepare('DELETE FROM plugins').run();
|
||||
|
||||
@@ -17,7 +17,7 @@ const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
|
||||
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
@@ -43,10 +43,10 @@ describe('per-user plugin settings', () => {
|
||||
let svc: PluginsService;
|
||||
// AddonsService only feeds PluginsService.list(), which no case here calls, but it is a
|
||||
// real collaborator on the same connection rather than a stand-in.
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
getDb.current = freshDb();
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
svc = new PluginsService(dbs, new AddonsService(dbs));
|
||||
svc = new PluginsService(dbs, await createTestAddonsService(getDb.current as Database.Database, dbs));
|
||||
});
|
||||
|
||||
it('lists only the user-scope fields, in order', async () => {
|
||||
@@ -95,10 +95,10 @@ describe('manifest defaults reach the runtime reads', () => {
|
||||
// through ctx.settings.get() / the channel dispatch's readAll — otherwise a plugin
|
||||
// that ships a sensible default serves nobody until every user opens the form.
|
||||
let svc: PluginsService;
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
getDb.current = freshDb();
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
svc = new PluginsService(dbs, new AddonsService(dbs));
|
||||
svc = new PluginsService(dbs, await createTestAddonsService(getDb.current as Database.Database, dbs));
|
||||
const ins = (getDb.current as import('better-sqlite3').Database).prepare(
|
||||
'INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order, default_value) VALUES (?,?,?,?,?,?,?,?)',
|
||||
);
|
||||
@@ -138,10 +138,10 @@ describe('hasRequired applies the same "filled" rule as the save gate', () => {
|
||||
// it disagreed with assertRequiredFilled() a save the form accepted could still leave
|
||||
// the user "not configured" (or the reverse).
|
||||
let svc: PluginsService;
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
getDb.current = freshDb();
|
||||
const dbs = new DatabaseService(dbConn);
|
||||
svc = new PluginsService(dbs, new AddonsService(dbs));
|
||||
svc = new PluginsService(dbs, await createTestAddonsService(getDb.current as Database.Database, dbs));
|
||||
(getDb.current as import('better-sqlite3').Database)
|
||||
.prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order) VALUES (?,?,?,?,?,?,?)')
|
||||
.run('p', 'consent', 'checkbox', 1, 0, 'user', 9);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* The read-side plugin service + controller (#plugins, M0). Lists installed
|
||||
* plugins and reports whether the runtime is enabled (TREK_PLUGINS_ENABLED).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
const { testDb } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -20,12 +20,21 @@ vi.mock('../../../src/db/database', () => ({ db: testDb }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
|
||||
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
|
||||
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
|
||||
// AddonsService only feeds PluginsService.list()'s required-addon-dependency
|
||||
// resolution; none of the fixtures below declare any, so it is built once
|
||||
// (over the same connection) rather than reconstructed at every call site.
|
||||
let addonsService: AddonsService;
|
||||
beforeAll(async () => {
|
||||
addonsService = await createTestAddonsService(testDb, new DatabaseService(dbConn));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.exec('DELETE FROM plugins');
|
||||
testDb.exec('DELETE FROM plugin_settings_fields');
|
||||
@@ -42,7 +51,7 @@ describe('PluginsService.list', () => {
|
||||
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
expect(out.enabled).toBe(true);
|
||||
expect(out.plugins).toHaveLength(1);
|
||||
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
|
||||
@@ -63,7 +72,7 @@ describe('PluginsService.list', () => {
|
||||
it('reports the switch off and an outgrown plugin as hostIncompatible by default', async () => {
|
||||
process.env.APP_VERSION = '4.1.0';
|
||||
seed();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
expect(out.ignoreTrekRange).toBe(false);
|
||||
expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'hostIncompatible', trekRangeBypassed: null });
|
||||
});
|
||||
@@ -72,7 +81,7 @@ describe('PluginsService.list', () => {
|
||||
process.env.APP_VERSION = '4.1.0';
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
seed();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
expect(out.ignoreTrekRange).toBe(true);
|
||||
expect(out.plugins[0]).toMatchObject({
|
||||
dependencyStatus: 'ok',
|
||||
@@ -84,7 +93,7 @@ describe('PluginsService.list', () => {
|
||||
process.env.APP_VERSION = '3.5.0';
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
seed();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'ok', trekRangeBypassed: null });
|
||||
});
|
||||
});
|
||||
@@ -97,7 +106,7 @@ describe('PluginsService.list', () => {
|
||||
.prepare("INSERT INTO plugins (id, name, type, status, version) VALUES ('free','Free','widget','inactive','1.0.0')")
|
||||
.run();
|
||||
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
expect(out.plugins.find((p) => p.id === 'held')).toMatchObject({ updateHold: true });
|
||||
expect(out.plugins.find((p) => p.id === 'free')).toMatchObject({ updateHold: false });
|
||||
});
|
||||
@@ -106,7 +115,7 @@ describe('PluginsService.list', () => {
|
||||
testDb
|
||||
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
|
||||
.run();
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
|
||||
|
||||
expect(await svc.resumeUpdates('held')).toBe(true);
|
||||
expect(testDb.prepare("SELECT update_hold FROM plugins WHERE id='held'").get()).toMatchObject({ update_hold: 0 });
|
||||
@@ -118,14 +127,14 @@ describe('PluginsService.list', () => {
|
||||
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
|
||||
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
|
||||
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
expect(out.enabled).toBe(true);
|
||||
expect(out.plugins).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', async () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
||||
const out = await new PluginsService(new DatabaseService(dbConn), addonsService).list();
|
||||
expect(out.enabled).toBe(false);
|
||||
expect(out.plugins).toEqual([]);
|
||||
});
|
||||
@@ -145,7 +154,7 @@ describe('PluginsService.list', () => {
|
||||
const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd';
|
||||
insert('signed-one', 'acme/signed-one', key);
|
||||
|
||||
const p = (await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()).plugins[0];
|
||||
const p = (await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins[0];
|
||||
expect(p.signed).toBe(true);
|
||||
// Short head…tail, for eyeballing against what the author reads out over the
|
||||
// phone. NOT a confidentiality measure — the key is public, and the re-trust
|
||||
@@ -155,7 +164,7 @@ describe('PluginsService.list', () => {
|
||||
|
||||
it('reports unsigned for a registry plugin with no pinned key', async () => {
|
||||
insert('plain', 'acme/plain', null);
|
||||
const p = (await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()).plugins[0];
|
||||
const p = (await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins[0];
|
||||
expect(p.signed).toBe(false);
|
||||
expect(p.keyFingerprint).toBeNull();
|
||||
});
|
||||
@@ -163,7 +172,7 @@ describe('PluginsService.list', () => {
|
||||
it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', async () => {
|
||||
insert('uploaded', 'local:upload', null);
|
||||
insert('linked', 'local:link', null);
|
||||
const plugins = (await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()).plugins;
|
||||
const plugins = (await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins;
|
||||
expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([
|
||||
['linked', false, 'local:link'],
|
||||
['uploaded', false, 'local:upload'],
|
||||
@@ -177,7 +186,7 @@ describe('PluginsService.list', () => {
|
||||
.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?')
|
||||
.run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked');
|
||||
|
||||
const byId = Object.fromEntries((await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list()).plugins.map((p) => [p.id, p]));
|
||||
const byId = Object.fromEntries((await new PluginsService(new DatabaseService(dbConn), addonsService).list()).plugins.map((p) => [p.id, p]));
|
||||
expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' });
|
||||
expect(byId.fine.updateBlock).toBeNull();
|
||||
});
|
||||
@@ -187,7 +196,7 @@ describe('PluginsService.list', () => {
|
||||
// PluginListItem is an interface, so it has no implicit index signature and cannot
|
||||
// be narrowed to a record directly. Widening through unknown is what lets this case
|
||||
// probe for a key the contract deliberately does not declare.
|
||||
const p = (await new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list())
|
||||
const p = (await new PluginsService(new DatabaseService(dbConn), addonsService).list())
|
||||
.plugins[0] as unknown as Record<string, unknown>;
|
||||
expect(p.author_pubkey).toBeUndefined();
|
||||
});
|
||||
@@ -314,7 +323,7 @@ describe('PluginsService instance config', () => {
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run();
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','server','instance',0)").run();
|
||||
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
|
||||
const masked = await svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
|
||||
// client gets the masked view
|
||||
expect(masked.api_key).toBe('••••••••');
|
||||
@@ -338,7 +347,7 @@ describe('PluginsService instance config', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('y','Y','inactive','{}')").run();
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('y','server','instance',0)").run();
|
||||
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
|
||||
const masked = await svc.updateInstanceConfig('y', { server: 'https://h', smuggled: 'nope' });
|
||||
|
||||
expect(masked.server).toBe('https://h');
|
||||
@@ -348,8 +357,8 @@ describe('PluginsService instance config', () => {
|
||||
});
|
||||
|
||||
it('throws for an unknown plugin', async () => {
|
||||
await expect(new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).updateInstanceConfig('nope', {})).rejects.toThrow(/not found/);
|
||||
await expect(new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).getInstanceConfig('nope')).rejects.toThrow(/not found/);
|
||||
await expect(new PluginsService(new DatabaseService(dbConn), addonsService).updateInstanceConfig('nope', {})).rejects.toThrow(/not found/);
|
||||
await expect(new PluginsService(new DatabaseService(dbConn), addonsService).getInstanceConfig('nope')).rejects.toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -357,7 +366,7 @@ describe('PluginsService error log', () => {
|
||||
beforeEach(() => testDb.exec('DELETE FROM plugin_error_log'));
|
||||
it('lists and clears a plugin error log', async () => {
|
||||
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run();
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn)));
|
||||
const svc = new PluginsService(new DatabaseService(dbConn), addonsService);
|
||||
expect(await svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
|
||||
await svc.clearErrors('p');
|
||||
expect(await svc.errors('p')).toEqual([]);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { PluginGuards } from '../../../src/nest/plugins/host/plugin-guards.service';
|
||||
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
|
||||
@@ -64,7 +64,7 @@ beforeAll(async () => {
|
||||
t = await createTestOrm(testDb, { allowGlobalContext: false });
|
||||
const dbs = new DatabaseService(testDb);
|
||||
permissions = new PermissionsService(t.repo(AppSettings) as AppSettingsRepository, new UnitOfWork(t.em));
|
||||
guards = new PluginGuards(dbs, permissions, new AddonsService(dbs));
|
||||
guards = new PluginGuards(dbs, permissions, await createTestAddonsService(testDb, dbs));
|
||||
userId = createUser(testDb, { role: 'user' }).user.id;
|
||||
// An admin has tightened trip_create from its 'everybody' default.
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_trip_create', 'admin')").run();
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('../../../src/db/database', () => dbMock);
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
@@ -140,7 +140,7 @@ describe('a plugin channel label is bounded by the host', () => {
|
||||
const rt = new PluginRuntimeService(
|
||||
new DatabaseService(dbConn),
|
||||
new AuditService(t.repo(AuditLog) as AuditLogRepository, t.repo(Users) as UsersRepository),
|
||||
new AddonsService(new DatabaseService(dbConn)),
|
||||
await createTestAddonsService(dbConn),
|
||||
userSettings(),
|
||||
);
|
||||
// Stand the plugin up as a granted, active notificationChannel provider.
|
||||
|
||||
Reference in New Issue
Block a user