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:
@@ -151,7 +151,8 @@ describe('BackendSelectionStep', () => {
|
|||||||
const nameInput = screen.getByRole('textbox');
|
const nameInput = screen.getByRole('textbox');
|
||||||
await user.clear(nameInput);
|
await user.clear(nameInput);
|
||||||
// Submit via Next button with empty name to trigger validation failure
|
// 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 user.click(nextButton);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
|
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// src/generators/rclone-conf.test.ts
|
// src/generators/rclone-conf.test.ts
|
||||||
// Tests for buildRcloneConf — rclone INI config generator.
|
// 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 { INITIAL_STATE } from '../store/types';
|
||||||
import type { WizardState } from '../store/types';
|
import type { WizardState } from '../store/types';
|
||||||
|
|
||||||
@@ -273,3 +273,137 @@ describe('buildRcloneConf — b2', () => {
|
|||||||
expect(buildRcloneConf(b2State)).toContain('key = APP_KEY_SECRET');
|
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 =');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,14 +7,27 @@ import type { WizardState } from '../store/types';
|
|||||||
// Maps our BackendType to rclone's internal type string.
|
// Maps our BackendType to rclone's internal type string.
|
||||||
// IMPORTANT: s3-compatible uses 'type = s3' — rclone does not recognize 's3-compatible' as a type.
|
// 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.
|
// 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',
|
azureblob: 'azureblob',
|
||||||
s3: 's3',
|
s3: 's3',
|
||||||
's3-compatible': 's3',
|
's3-compatible': 's3',
|
||||||
onedrive: 'onedrive',
|
|
||||||
sftp: 'sftp',
|
|
||||||
gcs: 'google cloud storage',
|
gcs: 'google cloud storage',
|
||||||
b2: 'b2',
|
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 {
|
export function buildRcloneConf(state: WizardState): string {
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { BACKEND_SCHEMAS } from './index';
|
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', () => {
|
describe('Azure Blob Zod schema', () => {
|
||||||
it('accepts valid account + access key', () => {
|
it('accepts valid account + access key', () => {
|
||||||
@@ -70,3 +99,65 @@ describe('S3-compatible Zod schema', () => {
|
|||||||
expect(result.success).toBe(false);
|
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
@@ -2,6 +2,7 @@
|
|||||||
// Zod schemas derived programmatically from the Backend Schema Registry.
|
// 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.
|
// 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.
|
// 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 { z } from 'zod';
|
||||||
import { BACKEND_REGISTRY, BackendType } from './registry';
|
import { BACKEND_REGISTRY, BackendType } from './registry';
|
||||||
@@ -27,13 +28,8 @@ function buildZodSchema(backendType: BackendType): z.ZodObject<Record<string, z.
|
|||||||
return z.object(shape);
|
return z.object(shape);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BACKEND_SCHEMAS = {
|
// Auto-generated from registry keys — expands automatically when BACKEND_REGISTRY grows.
|
||||||
azureblob: buildZodSchema('azureblob'),
|
// No manual buildZodSchema() call needed per new backend.
|
||||||
s3: buildZodSchema('s3'),
|
export const BACKEND_SCHEMAS = Object.fromEntries(
|
||||||
's3-compatible': buildZodSchema('s3-compatible'),
|
(Object.keys(BACKEND_REGISTRY) as BackendType[]).map(t => [t, buildZodSchema(t)])
|
||||||
onedrive: buildZodSchema('onedrive'),
|
) as Record<BackendType, ReturnType<typeof buildZodSchema>>;
|
||||||
sftp: buildZodSchema('sftp'),
|
|
||||||
gcs: buildZodSchema('gcs'),
|
|
||||||
b2: buildZodSchema('b2'),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user