feat(13-01): auto-generate BACKEND_SCHEMAS, update RCLONE_TYPE_MAP for all 18 backends

- BACKEND_SCHEMAS now auto-generated via Object.fromEntries over registry keys
- RCLONE_TYPE_MAP expanded with 11 new entries for all 18 backend types
- RCLONE_TYPE_MAP exported for direct testing
- index.test.ts: added auto-generation coverage (3 tests) + 4 new backend schema smoke tests
- rclone-conf.test.ts: added RCLONE_TYPE_MAP exhaustiveness test + gdrive/ftp/smb conf output tests
- Rule 1 fix: BackendSelectionStep test used /next/i regex matching WebDAV "NextCloud" card buttons
This commit is contained in:
2026-04-01 16:20:18 +02:00
parent b8c64937ea
commit 3010cdb0c6
5 changed files with 251 additions and 16 deletions
@@ -151,7 +151,8 @@ describe('BackendSelectionStep', () => {
const nameInput = screen.getByRole('textbox');
await user.clear(nameInput);
// Submit via Next button with empty name to trigger validation failure
const nextButton = screen.getByRole('button', { name: /next/i });
// Use exact match to avoid matching backend cards with "Next" in description (e.g. WebDAV/NextCloud)
const nextButton = screen.getByRole('button', { name: 'Next' });
await user.click(nextButton);
await waitFor(() => {
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
+136 -2
View File
@@ -1,8 +1,8 @@
// src/generators/rclone-conf.test.ts
// Tests for buildRcloneConf — rclone INI config generator.
// RED: imports will fail until Plan 02-02 creates rclone-conf.ts.
import { buildRcloneConf } from './rclone-conf';
import { buildRcloneConf, RCLONE_TYPE_MAP } from './rclone-conf';
import { BACKEND_REGISTRY, BackendType } from '../schemas/registry';
import { INITIAL_STATE } from '../store/types';
import type { WizardState } from '../store/types';
@@ -273,3 +273,137 @@ describe('buildRcloneConf — b2', () => {
expect(buildRcloneConf(b2State)).toContain('key = APP_KEY_SECRET');
});
});
// --- RCLONE_TYPE_MAP coverage ---
describe('RCLONE_TYPE_MAP', () => {
it('has an entry for every BackendType in BACKEND_REGISTRY', () => {
const registryKeys = Object.keys(BACKEND_REGISTRY) as BackendType[];
for (const key of registryKeys) {
expect(RCLONE_TYPE_MAP[key], `RCLONE_TYPE_MAP["${key}"]`).toBeDefined();
}
});
it('maps gdrive to drive (not gdrive)', () => {
expect(RCLONE_TYPE_MAP['gdrive']).toBe('drive');
});
it('maps azure-files to azurefiles (no hyphen)', () => {
expect(RCLONE_TYPE_MAP['azure-files']).toBe('azurefiles');
});
it('maps s3-compatible to s3 (provider=Other differentiates)', () => {
expect(RCLONE_TYPE_MAP['s3-compatible']).toBe('s3');
});
it('maps gcs to google cloud storage (with spaces)', () => {
expect(RCLONE_TYPE_MAP['gcs']).toBe('google cloud storage');
});
});
// --- New backend rclone.conf output tests ---
const gdriveState: WizardState = {
...INITIAL_STATE,
remote: {
name: 'my-gdrive',
backendType: 'gdrive',
params: {
token: '{"access_token":"GTOKEN","token_type":"Bearer"}',
root_folder_id: '',
service_account_credentials: '',
},
},
};
const ftpState: WizardState = {
...INITIAL_STATE,
remote: {
name: 'my-ftp',
backendType: 'ftp',
params: {
host: 'ftp.example.com',
user: 'ftpuser',
pass: 'ftppass',
port: '',
explicit_tls: '',
},
},
};
const smbState: WizardState = {
...INITIAL_STATE,
remote: {
name: 'my-smb',
backendType: 'smb',
params: {
host: 'fileserver.local',
user: 'domain\\admin',
pass: 'secret',
domain: 'CORP',
port: '',
},
},
};
describe('buildRcloneConf — gdrive (new)', () => {
it('contains type = drive (not type = gdrive)', () => {
const output = buildRcloneConf(gdriveState);
expect(output).toContain('type = drive');
expect(output).not.toContain('type = gdrive');
});
it('contains [my-gdrive] section header', () => {
expect(buildRcloneConf(gdriveState)).toContain('[my-gdrive]');
});
it('contains token value', () => {
expect(buildRcloneConf(gdriveState)).toContain('token = ');
});
it('omits empty root_folder_id', () => {
expect(buildRcloneConf(gdriveState)).not.toContain('root_folder_id');
});
it('omits empty service_account_credentials', () => {
expect(buildRcloneConf(gdriveState)).not.toContain('service_account_credentials');
});
});
describe('buildRcloneConf — ftp (new)', () => {
it('contains type = ftp', () => {
expect(buildRcloneConf(ftpState)).toContain('type = ftp');
});
it('contains host', () => {
expect(buildRcloneConf(ftpState)).toContain('host = ftp.example.com');
});
it('contains user', () => {
expect(buildRcloneConf(ftpState)).toContain('user = ftpuser');
});
it('contains pass', () => {
expect(buildRcloneConf(ftpState)).toContain('pass = ftppass');
});
it('omits empty port', () => {
expect(buildRcloneConf(ftpState)).not.toContain('port =');
});
it('omits empty explicit_tls', () => {
expect(buildRcloneConf(ftpState)).not.toContain('explicit_tls');
});
});
describe('buildRcloneConf — smb (new)', () => {
it('contains type = smb', () => {
expect(buildRcloneConf(smbState)).toContain('type = smb');
});
it('contains host', () => {
expect(buildRcloneConf(smbState)).toContain('host = fileserver.local');
});
it('contains user', () => {
expect(buildRcloneConf(smbState)).toContain('user = domain\\admin');
});
it('contains pass', () => {
expect(buildRcloneConf(smbState)).toContain('pass = secret');
});
it('contains domain', () => {
expect(buildRcloneConf(smbState)).toContain('domain = CORP');
});
it('omits empty port', () => {
expect(buildRcloneConf(smbState)).not.toContain('port =');
});
});
+16 -3
View File
@@ -7,14 +7,27 @@ import type { WizardState } from '../store/types';
// Maps our BackendType to rclone's internal type string.
// IMPORTANT: s3-compatible uses 'type = s3' — rclone does not recognize 's3-compatible' as a type.
// S3-compatible backends are differentiated by provider = Other in params.
const RCLONE_TYPE_MAP: Record<string, string> = {
// IMPORTANT: gdrive uses 'type = drive' — rclone's internal name for Google Drive.
// IMPORTANT: gcs uses 'type = google cloud storage' (with spaces) — rclone's internal name.
export const RCLONE_TYPE_MAP: Record<string, string> = {
azureblob: 'azureblob',
s3: 's3',
's3-compatible': 's3',
onedrive: 'onedrive',
sftp: 'sftp',
gcs: 'google cloud storage',
b2: 'b2',
'azure-files': 'azurefiles',
swift: 'swift',
onedrive: 'onedrive',
gdrive: 'drive',
dropbox: 'dropbox',
box: 'box',
pcloud: 'pcloud',
sftp: 'sftp',
ftp: 'ftp',
webdav: 'webdav',
smb: 'smb',
http: 'http',
seafile: 'seafile',
};
export function buildRcloneConf(state: WizardState): string {
+91
View File
@@ -1,5 +1,34 @@
import { describe, it, expect } from 'vitest';
import { BACKEND_SCHEMAS } from './index';
import { BACKEND_REGISTRY, BackendType } from './registry';
import { z } from 'zod';
// --- Auto-generation coverage ---
describe('BACKEND_SCHEMAS auto-generation', () => {
it('has an entry for every key in BACKEND_REGISTRY (no undefined)', () => {
const registryKeys = Object.keys(BACKEND_REGISTRY) as BackendType[];
for (const key of registryKeys) {
expect(BACKEND_SCHEMAS[key], `BACKEND_SCHEMAS.${key}`).toBeDefined();
}
});
it('each schema is a ZodObject (not undefined or null)', () => {
const registryKeys = Object.keys(BACKEND_REGISTRY) as BackendType[];
for (const key of registryKeys) {
const schema = BACKEND_SCHEMAS[key];
expect(schema, `BACKEND_SCHEMAS.${key}`).toBeInstanceOf(z.ZodObject);
}
});
it('schema count matches registry count', () => {
const registryCount = Object.keys(BACKEND_REGISTRY).length;
const schemasCount = Object.keys(BACKEND_SCHEMAS).length;
expect(schemasCount).toBe(registryCount);
});
});
// --- Existing schema behaviour tests (regression) ---
describe('Azure Blob Zod schema', () => {
it('accepts valid account + access key', () => {
@@ -70,3 +99,65 @@ describe('S3-compatible Zod schema', () => {
expect(result.success).toBe(false);
});
});
// --- New backend schema smoke tests ---
describe('FTP Zod schema (new)', () => {
it('accepts valid FTP credentials (host only — all others optional)', () => {
const result = BACKEND_SCHEMAS.ftp.safeParse({ host: 'ftp.example.com' });
expect(result.success).toBe(true);
});
it('rejects missing host (required)', () => {
const result = BACKEND_SCHEMAS.ftp.safeParse({ user: 'anon' });
expect(result.success).toBe(false);
});
});
describe('SMB Zod schema (new)', () => {
it('accepts valid SMB credentials', () => {
const result = BACKEND_SCHEMAS.smb.safeParse({
host: 'fileserver.example.com',
user: 'admin',
});
expect(result.success).toBe(true);
});
it('rejects missing host (required)', () => {
const result = BACKEND_SCHEMAS.smb.safeParse({ user: 'admin' });
expect(result.success).toBe(false);
});
});
describe('Seafile Zod schema (new)', () => {
it('accepts valid Seafile credentials', () => {
const result = BACKEND_SCHEMAS.seafile.safeParse({
url: 'https://cloud.seafile.com',
user: 'user@example.com',
pass: 'mypassword',
});
expect(result.success).toBe(true);
});
it('rejects missing url (required)', () => {
const result = BACKEND_SCHEMAS.seafile.safeParse({
user: 'user@example.com',
pass: 'mypassword',
});
expect(result.success).toBe(false);
});
});
describe('Dropbox Zod schema (new)', () => {
it('accepts valid dropbox token', () => {
const result = BACKEND_SCHEMAS.dropbox.safeParse({
token: '{"access_token":"TOKEN","token_type":"Bearer"}',
});
expect(result.success).toBe(true);
});
it('rejects missing token (required)', () => {
const result = BACKEND_SCHEMAS.dropbox.safeParse({});
expect(result.success).toBe(false);
});
});
+6 -10
View File
@@ -2,6 +2,7 @@
// Zod schemas derived programmatically from the Backend Schema Registry.
// DO NOT hand-write z.object() calls with hardcoded field names — all shapes come from the registry.
// Adding a field to BACKEND_REGISTRY automatically adds it to validation.
// BACKEND_SCHEMAS is auto-generated from registry keys — no manual per-backend calls needed.
import { z } from 'zod';
import { BACKEND_REGISTRY, BackendType } from './registry';
@@ -27,13 +28,8 @@ function buildZodSchema(backendType: BackendType): z.ZodObject<Record<string, z.
return z.object(shape);
}
export const BACKEND_SCHEMAS = {
azureblob: buildZodSchema('azureblob'),
s3: buildZodSchema('s3'),
's3-compatible': buildZodSchema('s3-compatible'),
onedrive: buildZodSchema('onedrive'),
sftp: buildZodSchema('sftp'),
gcs: buildZodSchema('gcs'),
b2: buildZodSchema('b2'),
} as const;
// Auto-generated from registry keys — expands automatically when BACKEND_REGISTRY grows.
// No manual buildZodSchema() call needed per new backend.
export const BACKEND_SCHEMAS = Object.fromEntries(
(Object.keys(BACKEND_REGISTRY) as BackendType[]).map(t => [t, buildZodSchema(t)])
) as Record<BackendType, ReturnType<typeof buildZodSchema>>;