mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(client): storage admin draft-document helpers + relative-time util
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MASKED_SETTING_VALUE, type StorageAdminState, type StorageConfig } from '@trek/shared';
|
||||
import {
|
||||
categoriesPointingAt,
|
||||
hasPlaintextSecret,
|
||||
mirrorsReferencing,
|
||||
removeBackend,
|
||||
settingsDocumentOf,
|
||||
upsertBackend,
|
||||
} from './storageModel';
|
||||
|
||||
const STATE: StorageAdminState = {
|
||||
backends: [
|
||||
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] },
|
||||
{ name: 'place-photos-local', type: 'local', source: 'env', options: { root: '/photos' }, categories: ['places'] },
|
||||
{
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
source: 'settings',
|
||||
options: {
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
},
|
||||
categories: ['covers'],
|
||||
},
|
||||
],
|
||||
categories: {
|
||||
files: { backend: 'uploads-local', source: 'default' },
|
||||
journey: { backend: 'uploads-local', source: 'default' },
|
||||
covers: { backend: 'off-box', source: 'settings' },
|
||||
avatars: { backend: 'uploads-local', source: 'default' },
|
||||
places: { backend: 'place-photos-local', source: 'default' },
|
||||
photos: { backend: 'uploads-local', source: 'default' },
|
||||
'photos-google': { backend: 'place-photos-local', source: 'default' },
|
||||
'photos-trek': { backend: 'uploads-local', source: 'default' },
|
||||
backups: { backend: 'backups-local', source: 'default' },
|
||||
},
|
||||
health: { replicaFailures: [] },
|
||||
encryptionReady: true,
|
||||
seedFilePresent: false,
|
||||
};
|
||||
|
||||
describe('settingsDocumentOf', () => {
|
||||
it('FE-ADMIN-STORM-001: carries ONLY settings-sourced backends and categories (the PUT contract)', () => {
|
||||
const doc = settingsDocumentOf(STATE);
|
||||
expect(doc.backends.map((b) => b.name)).toEqual(['off-box']);
|
||||
expect(doc.categories).toEqual({ covers: 'off-box' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove pre-check helpers', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
|
||||
{ name: 'mir', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } },
|
||||
],
|
||||
categories: { covers: 'nas', backups: 'mir' },
|
||||
};
|
||||
|
||||
it('FE-ADMIN-STORM-002: lists categories pointing at a backend', () => {
|
||||
expect(categoriesPointingAt(draft, 'nas')).toEqual(['covers']);
|
||||
expect(categoriesPointingAt(draft, 'unused')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-003: lists mirrors referencing a backend (primary or replica)', () => {
|
||||
expect(mirrorsReferencing(draft, 'nas')).toEqual(['mir']);
|
||||
expect(mirrorsReferencing(draft, 'backups-local')).toEqual(['mir']);
|
||||
expect(mirrorsReferencing(draft, 'unused')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPlaintextSecret', () => {
|
||||
const s3 = (secretAccessKey: string) => ({
|
||||
type: 's3' as const,
|
||||
options: { endpoint: 'http://x', bucket: 'b', accessKeyId: 'ak', secretAccessKey },
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-004: mask and empty are not plaintext; anything else is; local never is', () => {
|
||||
expect(hasPlaintextSecret(s3(MASKED_SETTING_VALUE))).toBe(false);
|
||||
expect(hasPlaintextSecret(s3(''))).toBe(false);
|
||||
expect(hasPlaintextSecret(s3('sk-plain'))).toBe(true);
|
||||
expect(hasPlaintextSecret({ type: 'local', options: { root: '/x' } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('draft edits', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }],
|
||||
categories: {},
|
||||
};
|
||||
|
||||
it('FE-ADMIN-STORM-005: upsert replaces by name in place, appends new ones, never mutates', () => {
|
||||
const edited = upsertBackend(draft, { name: 'nas', type: 'local', options: { root: '/mnt/nas2' } });
|
||||
expect(edited.backends).toEqual([{ name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }]);
|
||||
const added = upsertBackend(draft, { name: 'other', type: 'local', options: { root: '/o' } });
|
||||
expect(added.backends.map((b) => b.name)).toEqual(['nas', 'other']);
|
||||
expect(draft.backends).toHaveLength(1); // untouched
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-006: removeBackend filters by name', () => {
|
||||
expect(removeBackend(draft, 'nas').backends).toEqual([]);
|
||||
expect(removeBackend(draft, 'ghost').backends).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
MASKED_SETTING_VALUE,
|
||||
storageSecretFields,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
} from '@trek/shared'
|
||||
|
||||
/** One entry of the effective world (GET state), as the panels render it. */
|
||||
export type StateBackend = StorageAdminState['backends'][number]
|
||||
|
||||
/**
|
||||
* The settings-owned document the PUT carries: settings-sourced backends and
|
||||
* settings-sourced category assignments ONLY — built-ins and env backends are
|
||||
* never in the body unless the operator overrides one by name (in which case
|
||||
* the override already reports source 'settings').
|
||||
*/
|
||||
export function settingsDocumentOf(state: StorageAdminState): StorageConfig {
|
||||
return {
|
||||
backends: state.backends.filter((b) => b.source === 'settings').map(asWireBackend),
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(state.categories)
|
||||
.filter(([, entry]) => entry.source === 'settings')
|
||||
.map(([category, entry]) => [category, entry.backend]),
|
||||
) as StorageConfig['categories'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State options are already schema-shaped (the wire contract guarantees it;
|
||||
* secret fields carry the mask, which the server unmasks by name on PUT/test) —
|
||||
* the cast re-attaches the discriminated-union type the loose state record dropped.
|
||||
*/
|
||||
export function asWireBackend(backend: Pick<StateBackend, 'name' | 'type' | 'options'>): StorageBackend {
|
||||
return { name: backend.name, type: backend.type, options: backend.options } as StorageBackend
|
||||
}
|
||||
|
||||
/** Draft categories pointing at a backend name (the friendly remove pre-check). */
|
||||
export function categoriesPointingAt(draft: StorageConfig, name: string): StorageCategory[] {
|
||||
return (Object.entries(draft.categories) as Array<[StorageCategory, string]>)
|
||||
.filter(([, backend]) => backend === name)
|
||||
.map(([category]) => category)
|
||||
}
|
||||
|
||||
/** Draft mirrors referencing a backend name as primary or replica (remove pre-check). */
|
||||
export function mirrorsReferencing(draft: StorageConfig, name: string): string[] {
|
||||
return draft.backends
|
||||
.filter((b) => b.type === 'mirror' && (b.options.primary === name || b.options.replicas.includes(name)))
|
||||
.map((b) => b.name)
|
||||
}
|
||||
|
||||
/** True when a secret-kind field holds a non-empty value that is not the mask sentinel. */
|
||||
export function hasPlaintextSecret(backend: { type: StorageBackend['type']; options: Record<string, unknown> }): boolean {
|
||||
return storageSecretFields(backend.type).some((field) => {
|
||||
const value = backend.options[field]
|
||||
return typeof value === 'string' && value !== '' && value !== MASKED_SETTING_VALUE
|
||||
})
|
||||
}
|
||||
|
||||
/** Replace by name in place, or append. Never mutates. */
|
||||
export function upsertBackend(draft: StorageConfig, backend: StorageBackend): StorageConfig {
|
||||
const exists = draft.backends.some((b) => b.name === backend.name)
|
||||
return {
|
||||
...draft,
|
||||
backends: exists
|
||||
? draft.backends.map((b) => (b.name === backend.name ? backend : b))
|
||||
: [...draft.backends, backend],
|
||||
}
|
||||
}
|
||||
|
||||
export function removeBackend(draft: StorageConfig, name: string): StorageConfig {
|
||||
return { ...draft, backends: draft.backends.filter((b) => b.name !== name) }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { relativeTime } from './relativeTime';
|
||||
|
||||
describe('relativeTime', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
|
||||
it('FE-UTIL-RELTIME-001: formats seconds, minutes, hours and days', () => {
|
||||
expect(relativeTime(now - 30_000, 'en', now)).toBe('30 seconds ago');
|
||||
expect(relativeTime(now - 120_000, 'en', now)).toBe('2 minutes ago');
|
||||
expect(relativeTime(now - 2 * 3_600_000, 'en', now)).toBe('2 hours ago');
|
||||
expect(relativeTime(now - 3 * 86_400_000, 'en', now)).toBe('3 days ago');
|
||||
});
|
||||
|
||||
it('FE-UTIL-RELTIME-002: clamps future timestamps to "now"', () => {
|
||||
expect(relativeTime(now + 5_000, 'en', now)).toBe('now');
|
||||
});
|
||||
|
||||
it('FE-UTIL-RELTIME-003: localizes via Intl (de)', () => {
|
||||
expect(relativeTime(now - 120_000, 'de', now)).toBe('vor 2 Minuten');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
const STEPS: Array<{ limit: number; div: number; unit: Intl.RelativeTimeFormatUnit }> = [
|
||||
{ limit: 60_000, div: 1_000, unit: 'second' },
|
||||
{ limit: 3_600_000, div: 60_000, unit: 'minute' },
|
||||
{ limit: 86_400_000, div: 3_600_000, unit: 'hour' },
|
||||
{ limit: Number.POSITIVE_INFINITY, div: 86_400_000, unit: 'day' },
|
||||
]
|
||||
|
||||
/** "2 minutes ago" from an epoch-ms timestamp, localized. Future stamps clamp to "now". */
|
||||
export function relativeTime(at: number, locale: string, now: number = Date.now()): string {
|
||||
const elapsed = now - at
|
||||
if (elapsed < 1_000) return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(0, 'second')
|
||||
const step = STEPS.find((s) => elapsed < s.limit)!
|
||||
const value = Math.round(elapsed / step.div)
|
||||
return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-value, step.unit)
|
||||
}
|
||||
Reference in New Issue
Block a user