feat(server): give the memories endpoints their Zod contracts

The last nine memories entries in the body-contract allow list are gone: three
each on the immich, synology and unified controllers. They read @Body() as
Record<string, unknown> and coerced by hand.

The schemas are deliberately permissive and loose, and that is the point rather
than a shortcut. Paging arrives as `2` or `'2'` because the controller does
`Number(page) || 1`; synology_skip_ssl arrives as a boolean or the string
'true'; auto_upload is accepted in any shape because only a real boolean is
acted on. A schema that described the ideal body instead of the real one would
turn working clients into 400s, and parity outranks tightening. Narrowing them
is a separate, client-visible change.

Loose objects for the same reason: a strict object strips unknown keys, and
`selections[]` goes straight to the services as `Selection[]` - stripping there
would be a silent data loss the day a provider adds a field.

This unblocks be-memories-fold, which could not move a controller whose body had
no contract to move with it.
This commit is contained in:
Maurice
2026-08-08 11:58:18 +02:00
committed by Maurice
parent ac96644508
commit 97f524cdfd
8 changed files with 250 additions and 18 deletions
+1
View File
@@ -20,6 +20,7 @@ export * from './maps/maps.schema';
export * from './category/category.schema';
export * from './tag/tag.schema';
export * from './notification/notification.schema';
export * from './memories/memories.schema';
export * from './atlas/atlas.schema';
export * from './vacay/vacay.schema';
export * from './packing/packing.schema';
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import {
addTripPhotosSchema,
createAlbumLinkSchema,
immichSearchSchema,
immichSettingsSchema,
setTripPhotoSharingSchema,
synologySearchSchema,
synologySettingsSchema,
synologyTestSchema,
} from './memories.schema';
/**
* These contracts exist to describe what the endpoints already accept, not to
* tighten them — the nine routes were read as `Record<string, unknown>` and
* coerced by hand before. The cases below pin that tolerance, because a
* well-meaning narrowing here turns working clients into 400s.
*/
describe('immich contracts', () => {
it('accepts the settings body with an empty payload — every field is optional', () => {
expect(immichSettingsSchema.safeParse({}).success).toBe(true);
});
it('accepts auto_upload in any shape, since only a real boolean is acted on', () => {
for (const auto_upload of [true, false, 'yes', 1, null]) {
expect(immichSettingsSchema.safeParse({ auto_upload }).success).toBe(true);
}
});
it('accepts search paging as numbers OR strings (the controller does Number(x) || fallback)', () => {
expect(immichSearchSchema.safeParse({ page: 2, size: 50 }).success).toBe(true);
expect(immichSearchSchema.safeParse({ page: '2', size: '50' }).success).toBe(true);
});
it('rejects a page that is neither', () => {
expect(immichSearchSchema.safeParse({ page: { nope: true } }).success).toBe(false);
});
});
describe('synology contracts', () => {
it("accepts synology_skip_ssl as a boolean or the string 'true'/'false'", () => {
for (const synology_skip_ssl of [true, false, 'true', 'false']) {
expect(synologySettingsSchema.safeParse({ synology_skip_ssl }).success).toBe(true);
}
});
it('lets the test body carry an OTP on top of the settings fields', () => {
const parsed = synologyTestSchema.safeParse({
synology_url: 'https://nas.example.org',
synology_username: 'ada',
synology_password: 'secret',
synology_otp: '123456',
});
expect(parsed.success).toBe(true);
});
it('accepts every paging field of the search body, in either type', () => {
expect(synologySearchSchema.safeParse({ offset: '10', page: 2, limit: '100', size: 0 }).success).toBe(true);
});
});
describe('unified contracts', () => {
it('accepts a selection list and keeps unknown keys on the items', () => {
const parsed = addTripPhotosSchema.safeParse({
selections: [{ provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'p', future_field: 1 }],
shared: false,
});
expect(parsed.success).toBe(true);
// Loose on purpose: `selections` is handed to the services as `Selection[]`,
// so a stripped key would be a silent data loss the day a provider adds one.
expect(parsed.success && (parsed.data.selections![0] as Record<string, unknown>).future_field).toBe(1);
});
it('accepts a body with no selections at all (the controller falls back to [])', () => {
expect(addTripPhotosSchema.safeParse({}).success).toBe(true);
});
it('rejects a selection missing its asset ids', () => {
expect(addTripPhotosSchema.safeParse({ selections: [{ provider: 'immich' }] }).success).toBe(false);
});
it('accepts photo_id as a number or a string', () => {
expect(setTripPhotoSharingSchema.safeParse({ photo_id: 7, shared: true }).success).toBe(true);
expect(setTripPhotoSharingSchema.safeParse({ photo_id: '7', shared: true }).success).toBe(true);
});
it('leaves the album-link fields untyped — the service validates them, and it answers 400 with its own wording', () => {
expect(createAlbumLinkSchema.safeParse({}).success).toBe(true);
expect(createAlbumLinkSchema.safeParse({ provider: 'synology', album_id: 12, album_name: null }).success).toBe(true);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { z } from 'zod';
/**
* Contracts for the memories (photo provider) mutation endpoints.
*
* Deliberately permissive. Every one of these bodies was read as
* `Record<string, unknown>` and coerced by hand in the controller —
* `Number(page) || 1`, `body.synology_skip_ssl === 'true'`,
* `body?.shared === undefined ? true : !!body?.shared`. A strict schema would
* turn requests the client sends today into 400s, and the parity rule outranks
* tightening: these schemas describe the shape that already works, so the
* boot-time contract gate has something to point at and the client has a typed
* surface. Narrowing them is a separate, client-visible change.
*
* They are LOOSE objects for the same reason: a strict object strips unknown
* keys, and `selections[]` is handed straight to the services as `Selection[]`.
* Stripping there would silently drop a field a provider adds later.
*/
/** Accepts what `_parseStringBodyField` accepted: a string, or nothing. */
const optionalText = z.string().optional().nullable();
/** `x === true || x === 'true'` — the checkbox arrives either way. */
const looseBoolean = z.union([z.boolean(), z.literal('true'), z.literal('false')]).optional();
/** `Number(x) || fallback` — the client sends these as strings from query-ish forms. */
const looseNumber = z.union([z.number(), z.string()]).optional();
// ── Immich ────────────────────────────────────────────────────────────────
export const immichSettingsSchema = z.looseObject({
immich_url: optionalText,
immich_api_key: optionalText,
// Applied only when it is a real boolean (`typeof auto_upload === 'boolean'`),
// so anything else is accepted and ignored, exactly as before.
auto_upload: z.unknown().optional(),
});
export const immichTestSchema = z.looseObject({
immich_url: optionalText,
immich_api_key: optionalText,
});
export const immichSearchSchema = z.looseObject({
from: optionalText,
to: optionalText,
size: looseNumber,
page: looseNumber,
});
// ── Synology ──────────────────────────────────────────────────────────────
export const synologySettingsSchema = z.looseObject({
synology_url: optionalText,
synology_username: optionalText,
synology_password: optionalText,
synology_skip_ssl: looseBoolean,
});
export const synologyTestSchema = synologySettingsSchema.extend({
synology_otp: optionalText,
});
export const synologySearchSchema = z.looseObject({
from: optionalText,
to: optionalText,
offset: looseNumber,
page: looseNumber,
limit: looseNumber,
size: looseNumber,
});
// ── Unified (provider-agnostic trip photo surface) ────────────────────────
/** One provider's picked assets. Mirrors the `Selection` type the services take. */
export const photoSelectionSchema = z.looseObject({
provider: z.string(),
asset_ids: z.array(z.string()),
owner_id: z.number().optional(),
passphrase: z.string().optional(),
});
export const addTripPhotosSchema = z.looseObject({
// Non-arrays fall back to [] rather than 400 (`Array.isArray(body?.selections) ? … : []`).
selections: z.array(photoSelectionSchema).optional(),
// Absent means shared; anything else is read for truthiness.
shared: z.unknown().optional(),
});
export const setTripPhotoSharingSchema = z.looseObject({
photo_id: looseNumber,
shared: z.unknown().optional(),
});
export const createAlbumLinkSchema = z.looseObject({
provider: z.unknown().optional(),
album_id: z.unknown().optional(),
album_name: z.unknown().optional(),
passphrase: z.unknown().optional(),
});
export type ImmichSettingsInput = z.infer<typeof immichSettingsSchema>;
export type ImmichTestInput = z.infer<typeof immichTestSchema>;
export type ImmichSearchInput = z.infer<typeof immichSearchSchema>;
export type SynologySettingsInput = z.infer<typeof synologySettingsSchema>;
export type SynologyTestInput = z.infer<typeof synologyTestSchema>;
export type SynologySearchInput = z.infer<typeof synologySearchSchema>;
export type PhotoSelectionInput = z.infer<typeof photoSelectionSchema>;
export type AddTripPhotosInput = z.infer<typeof addTripPhotosSchema>;
export type SetTripPhotoSharingInput = z.infer<typeof setTripPhotoSharingSchema>;
export type CreateAlbumLinkInput = z.infer<typeof createAlbumLinkSchema>;