From 3010cdb0c625bf9e212da1586fb2ede94a426e65 Mon Sep 17 00:00:00 2001 From: Kawa Date: Wed, 1 Apr 2026 16:20:18 +0200 Subject: [PATCH] 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 --- .../wizard/BackendSelectionStep.test.tsx | 3 +- src/generators/rclone-conf.test.ts | 138 +++++++++++++++++- src/generators/rclone-conf.ts | 19 ++- src/schemas/index.test.ts | 91 ++++++++++++ src/schemas/index.ts | 16 +- 5 files changed, 251 insertions(+), 16 deletions(-) diff --git a/src/components/wizard/BackendSelectionStep.test.tsx b/src/components/wizard/BackendSelectionStep.test.tsx index 064dc39..e8de7bb 100644 --- a/src/components/wizard/BackendSelectionStep.test.tsx +++ b/src/components/wizard/BackendSelectionStep.test.tsx @@ -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(); diff --git a/src/generators/rclone-conf.test.ts b/src/generators/rclone-conf.test.ts index 37922dd..beaa949 100644 --- a/src/generators/rclone-conf.test.ts +++ b/src/generators/rclone-conf.test.ts @@ -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 ='); + }); +}); diff --git a/src/generators/rclone-conf.ts b/src/generators/rclone-conf.ts index 2e35325..81f11f8 100644 --- a/src/generators/rclone-conf.ts +++ b/src/generators/rclone-conf.ts @@ -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 = { +// 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 = { 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 { diff --git a/src/schemas/index.test.ts b/src/schemas/index.test.ts index da8fa5d..db7c3cc 100644 --- a/src/schemas/index.test.ts +++ b/src/schemas/index.test.ts @@ -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); + }); +}); diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 94817ce..eb0c4c4 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -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 [t, buildZodSchema(t)]) +) as Record>;