Merge fix/sieve-filter-save-21: fix sieve activation (RFC 9661) - fixes #21
This commit is contained in:
+39
-37
@@ -1502,17 +1502,22 @@ export class JMAPClient {
|
||||
throw new Error('Invalid upload response: blobId not found');
|
||||
}
|
||||
|
||||
async createSieveScript(name: string, content: string): Promise<SieveScript> {
|
||||
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
|
||||
const blobId = await this.uploadSieveBlob(content);
|
||||
const accountId = this.getSieveAccountId();
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
create: {
|
||||
"new-script": { name, blobId }
|
||||
},
|
||||
};
|
||||
if (activate) {
|
||||
setArgs.onSuccessActivateScript = "#new-script";
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["SieveScript/set", {
|
||||
accountId,
|
||||
create: {
|
||||
"new-script": { name, blobId }
|
||||
}
|
||||
}, "0"]
|
||||
["SieveScript/set", setArgs, "0"]
|
||||
], this.sieveUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
|
||||
@@ -1531,17 +1536,22 @@ export class JMAPClient {
|
||||
throw new Error("Failed to create sieve script");
|
||||
}
|
||||
|
||||
async updateSieveScript(scriptId: string, content: string): Promise<void> {
|
||||
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
|
||||
const blobId = await this.uploadSieveBlob(content);
|
||||
const accountId = this.getSieveAccountId();
|
||||
|
||||
const setArgs: Record<string, unknown> = {
|
||||
accountId,
|
||||
update: {
|
||||
[scriptId]: { blobId }
|
||||
},
|
||||
};
|
||||
if (activate) {
|
||||
setArgs.onSuccessActivateScript = scriptId;
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["SieveScript/set", {
|
||||
accountId,
|
||||
update: {
|
||||
[scriptId]: { blobId }
|
||||
}
|
||||
}, "0"]
|
||||
["SieveScript/set", setArgs, "0"]
|
||||
], this.sieveUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
|
||||
@@ -1582,44 +1592,36 @@ export class JMAPClient {
|
||||
const response = await this.request([
|
||||
["SieveScript/set", {
|
||||
accountId,
|
||||
update: {
|
||||
[scriptId]: { isActive: true }
|
||||
}
|
||||
onSuccessActivateScript: scriptId,
|
||||
}, "0"]
|
||||
], this.sieveUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
if (result.notUpdated?.[scriptId]) {
|
||||
const error = result.notUpdated[scriptId];
|
||||
throw new Error(error.description || "Failed to activate sieve script");
|
||||
}
|
||||
return;
|
||||
const [methodName, result] = response.methodResponses?.[0] || [];
|
||||
if (methodName === "error") {
|
||||
throw new Error(result?.description || "Failed to activate sieve script");
|
||||
}
|
||||
if (methodName !== "SieveScript/set") {
|
||||
throw new Error("Failed to activate sieve script");
|
||||
}
|
||||
throw new Error("Failed to activate sieve script");
|
||||
}
|
||||
|
||||
async deactivateSieveScript(scriptId: string): Promise<void> {
|
||||
async deactivateSieveScript(): Promise<void> {
|
||||
const accountId = this.getSieveAccountId();
|
||||
|
||||
const response = await this.request([
|
||||
["SieveScript/set", {
|
||||
accountId,
|
||||
update: {
|
||||
[scriptId]: { isActive: false }
|
||||
}
|
||||
onSuccessActivateScript: null,
|
||||
}, "0"]
|
||||
], this.sieveUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
if (result.notUpdated?.[scriptId]) {
|
||||
const error = result.notUpdated[scriptId];
|
||||
throw new Error(error.description || "Failed to deactivate sieve script");
|
||||
}
|
||||
return;
|
||||
const [methodName, result] = response.methodResponses?.[0] || [];
|
||||
if (methodName === "error") {
|
||||
throw new Error(result?.description || "Failed to deactivate sieve script");
|
||||
}
|
||||
if (methodName !== "SieveScript/set") {
|
||||
throw new Error("Failed to deactivate sieve script");
|
||||
}
|
||||
throw new Error("Failed to deactivate sieve script");
|
||||
}
|
||||
|
||||
async validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }> {
|
||||
|
||||
@@ -399,4 +399,72 @@ describe('generateScript', () => {
|
||||
const secondIdx = script.indexOf('# Rule: Second');
|
||||
expect(firstIdx).toBeLessThan(secondIdx);
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('skips rules with empty conditions', () => {
|
||||
const script = generateScript([makeRule({ name: 'Empty', conditions: [], actions: [{ type: 'keep' }] })]);
|
||||
expect(script).not.toContain('# Rule: Empty');
|
||||
expect(script).not.toContain('if ');
|
||||
});
|
||||
|
||||
it('skips rules with empty actions', () => {
|
||||
const script = generateScript([makeRule({ name: 'NoAction', actions: [] })]);
|
||||
expect(script).not.toContain('# Rule: NoAction');
|
||||
expect(script).not.toContain('if ');
|
||||
});
|
||||
|
||||
it('uses X-Unknown for header field without headerName', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'header', comparator: 'contains', value: 'test' }],
|
||||
})]);
|
||||
expect(script).toContain('header :contains "X-Unknown" "test"');
|
||||
});
|
||||
|
||||
it('handles all enabled rules with different extensions combined', () => {
|
||||
const rules = [
|
||||
makeRule({ id: '1', actions: [{ type: 'move', value: 'A' }] }),
|
||||
makeRule({ id: '2', actions: [{ type: 'reject', value: 'No' }] }),
|
||||
makeRule({ id: '3', conditions: [{ field: 'body', comparator: 'contains', value: 'x' }], actions: [{ type: 'star' }] }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const requireLine = script.split('\n').find(l => l.startsWith('require'))!;
|
||||
expect(requireLine).toContain('"fileinto"');
|
||||
expect(requireLine).toContain('"reject"');
|
||||
expect(requireLine).toContain('"body"');
|
||||
expect(requireLine).toContain('"imap4flags"');
|
||||
});
|
||||
|
||||
it('generates no require line when only keep/discard/stop/forward actions', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'keep' }, { type: 'forward', value: 'a@b.com' }],
|
||||
})]);
|
||||
expect(script).not.toContain('require');
|
||||
});
|
||||
|
||||
it('handles multiple actions on same rule', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [
|
||||
{ type: 'move', value: 'Folder' },
|
||||
{ type: 'mark_read' },
|
||||
{ type: 'star' },
|
||||
{ type: 'stop' },
|
||||
],
|
||||
})]);
|
||||
expect(script).toContain('fileinto "Folder";');
|
||||
expect(script).toContain('addflag "\\\\Seen";');
|
||||
expect(script).toContain('addflag "\\\\Flagged";');
|
||||
expect(script).toContain('stop;');
|
||||
});
|
||||
|
||||
it('generates valid script for all-disabled rules', () => {
|
||||
const rules = [
|
||||
makeRule({ id: '1', enabled: false }),
|
||||
makeRule({ id: '2', enabled: false }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
expect(script).toContain('@metadata:begin');
|
||||
expect(script).not.toContain('if ');
|
||||
expect(script).not.toContain('require');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,4 +144,70 @@ describe('parseScript', () => {
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation edge cases', () => {
|
||||
it('returns isOpaque when rule has non-string id', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: 123, name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when rule has non-boolean enabled', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: 'yes', matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when rule has invalid matchType', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'none', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when condition missing value', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains' }], actions: [{ type: 'keep' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('returns isOpaque when action missing type', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [{ id: '1', name: 'x', enabled: true, matchType: 'all', conditions: [{ field: 'from', comparator: 'contains', value: 'a' }], actions: [{ value: 'Inbox' }], stopProcessing: false }] })}\n@metadata:end */`;
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid empty rules array', () => {
|
||||
const script = `/* @metadata:begin\n${JSON.stringify({ version: 1, rules: [] })}\n@metadata:end */`;
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves all comparator types through round-trip', () => {
|
||||
const comparators = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'] as const;
|
||||
const rules = comparators.map((comparator, i) => makeRule({
|
||||
id: `r${i}`,
|
||||
name: `Rule ${comparator}`,
|
||||
conditions: [{ field: 'from', comparator, value: 'test' }],
|
||||
}));
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('preserves size comparators through round-trip', () => {
|
||||
const rules = [
|
||||
makeRule({ id: 'r1', conditions: [{ field: 'size', comparator: 'greater_than', value: '1000' }], actions: [{ type: 'discard' }] }),
|
||||
makeRule({ id: 'r2', conditions: [{ field: 'size', comparator: 'less_than', value: '500' }], actions: [{ type: 'keep' }] }),
|
||||
];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('preserves header field with custom headerName', () => {
|
||||
const rules = [makeRule({
|
||||
conditions: [{ field: 'header', comparator: 'contains', value: 'test', headerName: 'X-My-Header' }],
|
||||
})];
|
||||
const script = generateScript(rules);
|
||||
const result = parseScript(script);
|
||||
expect(result.rules).toEqual(rules);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useFilterStore } from '../filter-store';
|
||||
import type { FilterRule } from '@/lib/jmap/sieve-types';
|
||||
|
||||
const makeRule = (overrides: Partial<FilterRule> = {}): FilterRule => ({
|
||||
id: 'rule-1',
|
||||
name: 'Test Rule',
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: [{ field: 'from', comparator: 'contains', value: 'test@example.com' }],
|
||||
actions: [{ type: 'move', value: 'Archive' }],
|
||||
stopProcessing: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('filter-store', () => {
|
||||
beforeEach(() => {
|
||||
useFilterStore.getState().clearState();
|
||||
});
|
||||
|
||||
describe('addRule', () => {
|
||||
it('should append a rule', () => {
|
||||
useFilterStore.getState().addRule(makeRule());
|
||||
expect(useFilterStore.getState().rules).toHaveLength(1);
|
||||
expect(useFilterStore.getState().rules[0].id).toBe('rule-1');
|
||||
});
|
||||
|
||||
it('should not replace existing rules', () => {
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r1' }));
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r2' }));
|
||||
expect(useFilterStore.getState().rules).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRule', () => {
|
||||
it('should update matching rule', () => {
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r1', name: 'Old' }));
|
||||
useFilterStore.getState().updateRule('r1', { name: 'New' });
|
||||
expect(useFilterStore.getState().rules[0].name).toBe('New');
|
||||
});
|
||||
|
||||
it('should not modify other rules', () => {
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r1', name: 'A' }));
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r2', name: 'B' }));
|
||||
useFilterStore.getState().updateRule('r1', { name: 'Updated' });
|
||||
expect(useFilterStore.getState().rules[1].name).toBe('B');
|
||||
});
|
||||
|
||||
it('should no-op for non-existent rule', () => {
|
||||
useFilterStore.getState().addRule(makeRule());
|
||||
useFilterStore.getState().updateRule('nonexistent', { name: 'X' });
|
||||
expect(useFilterStore.getState().rules[0].name).toBe('Test Rule');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRule', () => {
|
||||
it('should remove rule by id', () => {
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r1' }));
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r2' }));
|
||||
useFilterStore.getState().deleteRule('r1');
|
||||
expect(useFilterStore.getState().rules).toHaveLength(1);
|
||||
expect(useFilterStore.getState().rules[0].id).toBe('r2');
|
||||
});
|
||||
|
||||
it('should no-op for unknown id', () => {
|
||||
useFilterStore.getState().addRule(makeRule());
|
||||
useFilterStore.getState().deleteRule('unknown');
|
||||
expect(useFilterStore.getState().rules).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleRule', () => {
|
||||
it('should toggle enabled to disabled', () => {
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r1', enabled: true }));
|
||||
useFilterStore.getState().toggleRule('r1');
|
||||
expect(useFilterStore.getState().rules[0].enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle disabled to enabled', () => {
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r1', enabled: false }));
|
||||
useFilterStore.getState().toggleRule('r1');
|
||||
expect(useFilterStore.getState().rules[0].enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should not affect other rules', () => {
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r1', enabled: true }));
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r2', enabled: true }));
|
||||
useFilterStore.getState().toggleRule('r1');
|
||||
expect(useFilterStore.getState().rules[1].enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderRules', () => {
|
||||
it('should reorder rules by id array', () => {
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r1', name: 'First' }));
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r2', name: 'Second' }));
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r3', name: 'Third' }));
|
||||
useFilterStore.getState().reorderRules(['r3', 'r1', 'r2']);
|
||||
const names = useFilterStore.getState().rules.map(r => r.name);
|
||||
expect(names).toEqual(['Third', 'First', 'Second']);
|
||||
});
|
||||
|
||||
it('should filter out unknown ids', () => {
|
||||
useFilterStore.getState().addRule(makeRule({ id: 'r1' }));
|
||||
useFilterStore.getState().reorderRules(['r1', 'unknown']);
|
||||
expect(useFilterStore.getState().rules).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRawScript', () => {
|
||||
it('should update rawScript', () => {
|
||||
useFilterStore.getState().setRawScript('require ["fileinto"];');
|
||||
expect(useFilterStore.getState().rawScript).toBe('require ["fileinto"];');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetToVisualBuilder', () => {
|
||||
it('should clear opaque state, rawScript, and rules', () => {
|
||||
useFilterStore.setState({ isOpaque: true, rawScript: 'some script', rules: [makeRule()] });
|
||||
useFilterStore.getState().resetToVisualBuilder();
|
||||
expect(useFilterStore.getState().isOpaque).toBe(false);
|
||||
expect(useFilterStore.getState().rawScript).toBe('');
|
||||
expect(useFilterStore.getState().rules).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearState', () => {
|
||||
it('should reset all state to defaults', () => {
|
||||
useFilterStore.setState({
|
||||
rules: [makeRule()],
|
||||
isLoading: true,
|
||||
isSaving: true,
|
||||
error: 'some error',
|
||||
isSupported: true,
|
||||
activeScriptId: 'script-1',
|
||||
isOpaque: true,
|
||||
rawScript: 'content',
|
||||
});
|
||||
useFilterStore.getState().clearState();
|
||||
const state = useFilterStore.getState();
|
||||
expect(state.rules).toEqual([]);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.isSaving).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
expect(state.isSupported).toBe(false);
|
||||
expect(state.activeScriptId).toBeNull();
|
||||
expect(state.isOpaque).toBe(false);
|
||||
expect(state.rawScript).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFilters', () => {
|
||||
it('should set isOpaque for scripts without metadata', async () => {
|
||||
const mockClient = {
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||
getSieveScriptContent: async () => 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }',
|
||||
};
|
||||
await useFilterStore.getState().fetchFilters(mockClient as any);
|
||||
expect(useFilterStore.getState().isOpaque).toBe(true);
|
||||
expect(useFilterStore.getState().rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse rules from metadata-bearing script', async () => {
|
||||
const rules = [makeRule()];
|
||||
const { generateScript } = await import('@/lib/sieve/generator');
|
||||
const script = generateScript(rules);
|
||||
const mockClient = {
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||
getSieveScriptContent: async () => script,
|
||||
};
|
||||
await useFilterStore.getState().fetchFilters(mockClient as any);
|
||||
expect(useFilterStore.getState().isOpaque).toBe(false);
|
||||
expect(useFilterStore.getState().rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('should handle empty script list', async () => {
|
||||
const mockClient = {
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [],
|
||||
getSieveScriptContent: async () => '',
|
||||
};
|
||||
await useFilterStore.getState().fetchFilters(mockClient as any);
|
||||
expect(useFilterStore.getState().rules).toEqual([]);
|
||||
expect(useFilterStore.getState().activeScriptId).toBeNull();
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const mockClient = {
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => { throw new Error('Network error'); },
|
||||
};
|
||||
await useFilterStore.getState().fetchFilters(mockClient as any);
|
||||
expect(useFilterStore.getState().error).toBe('Network error');
|
||||
expect(useFilterStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveFilters', () => {
|
||||
it('should call updateSieveScript with activate when activeScriptId exists', async () => {
|
||||
const calls: Array<{ method: string; args: unknown[] }> = [];
|
||||
const mockClient = {
|
||||
updateSieveScript: async (...args: unknown[]) => { calls.push({ method: 'updateSieveScript', args }); },
|
||||
createSieveScript: async (...args: unknown[]) => { calls.push({ method: 'createSieveScript', args }); return { id: 'new-id' }; },
|
||||
};
|
||||
useFilterStore.setState({
|
||||
activeScriptId: 'existing-id',
|
||||
rules: [makeRule()],
|
||||
isOpaque: false,
|
||||
});
|
||||
await useFilterStore.getState().saveFilters(mockClient as any);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].method).toBe('updateSieveScript');
|
||||
expect(calls[0].args[0]).toBe('existing-id');
|
||||
expect(calls[0].args[2]).toBe(true); // activate flag
|
||||
});
|
||||
|
||||
it('should call createSieveScript with activate when no activeScriptId', async () => {
|
||||
const calls: Array<{ method: string; args: unknown[] }> = [];
|
||||
const mockClient = {
|
||||
updateSieveScript: async (...args: unknown[]) => { calls.push({ method: 'updateSieveScript', args }); },
|
||||
createSieveScript: async (...args: unknown[]) => {
|
||||
calls.push({ method: 'createSieveScript', args });
|
||||
return { id: 'new-id', name: 'filters', blobId: 'b1', isActive: true };
|
||||
},
|
||||
};
|
||||
useFilterStore.setState({
|
||||
activeScriptId: null,
|
||||
rules: [makeRule()],
|
||||
isOpaque: false,
|
||||
});
|
||||
await useFilterStore.getState().saveFilters(mockClient as any);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].method).toBe('createSieveScript');
|
||||
expect(calls[0].args[2]).toBe(true); // activate flag
|
||||
expect(useFilterStore.getState().activeScriptId).toBe('new-id');
|
||||
});
|
||||
|
||||
it('should use rawScript when isOpaque', async () => {
|
||||
let savedContent = '';
|
||||
const mockClient = {
|
||||
updateSieveScript: async (_id: string, content: string) => { savedContent = content; },
|
||||
};
|
||||
useFilterStore.setState({
|
||||
activeScriptId: 'existing-id',
|
||||
rules: [],
|
||||
isOpaque: true,
|
||||
rawScript: 'require ["fileinto"];\nif header :contains "From" "x" { fileinto "Y"; }',
|
||||
});
|
||||
await useFilterStore.getState().saveFilters(mockClient as any);
|
||||
expect(savedContent).toContain('require ["fileinto"]');
|
||||
});
|
||||
|
||||
it('should set error on failure', async () => {
|
||||
const mockClient = {
|
||||
updateSieveScript: async () => { throw new Error('Server error'); },
|
||||
};
|
||||
useFilterStore.setState({ activeScriptId: 'existing-id', rules: [makeRule()] });
|
||||
await expect(useFilterStore.getState().saveFilters(mockClient as any)).rejects.toThrow('Server error');
|
||||
expect(useFilterStore.getState().error).toBe('Server error');
|
||||
expect(useFilterStore.getState().isSaving).toBe(false);
|
||||
});
|
||||
|
||||
it('should generate script content from rules when not opaque', async () => {
|
||||
let savedContent = '';
|
||||
const mockClient = {
|
||||
updateSieveScript: async (_id: string, content: string) => { savedContent = content; },
|
||||
};
|
||||
const rules = [makeRule({ name: 'My Filter' })];
|
||||
useFilterStore.setState({ activeScriptId: 'existing-id', rules, isOpaque: false });
|
||||
await useFilterStore.getState().saveFilters(mockClient as any);
|
||||
expect(savedContent).toContain('@metadata:begin');
|
||||
expect(savedContent).toContain('My Filter');
|
||||
expect(savedContent).toContain('fileinto "Archive"');
|
||||
});
|
||||
|
||||
it('should update rawScript after successful save', async () => {
|
||||
const mockClient = {
|
||||
updateSieveScript: async () => {},
|
||||
};
|
||||
useFilterStore.setState({ activeScriptId: 'existing-id', rules: [makeRule()], isOpaque: false, rawScript: '' });
|
||||
await useFilterStore.getState().saveFilters(mockClient as any);
|
||||
expect(useFilterStore.getState().rawScript).toContain('@metadata:begin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSupported', () => {
|
||||
it('should set isSupported flag', () => {
|
||||
useFilterStore.getState().setSupported(true);
|
||||
expect(useFilterStore.getState().isSupported).toBe(true);
|
||||
useFilterStore.getState().setSupported(false);
|
||||
expect(useFilterStore.getState().isSupported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFilters', () => {
|
||||
it('should prefer the active script over the first script', async () => {
|
||||
const { generateScript } = await import('@/lib/sieve/generator');
|
||||
const activeRules = [makeRule({ name: 'Active' })];
|
||||
const inactiveRules = [makeRule({ name: 'Inactive' })];
|
||||
|
||||
const mockClient = {
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [
|
||||
{ id: 's1', name: 'old', blobId: 'b1', isActive: false },
|
||||
{ id: 's2', name: 'main', blobId: 'b2', isActive: true },
|
||||
],
|
||||
getSieveScriptContent: async (blobId: string) => {
|
||||
if (blobId === 'b2') return generateScript(activeRules);
|
||||
return generateScript(inactiveRules);
|
||||
},
|
||||
};
|
||||
await useFilterStore.getState().fetchFilters(mockClient as any);
|
||||
expect(useFilterStore.getState().activeScriptId).toBe('s2');
|
||||
expect(useFilterStore.getState().rules[0].name).toBe('Active');
|
||||
});
|
||||
|
||||
it('should set sieveCapabilities from client', async () => {
|
||||
const caps = { implementation: 'test', maxSizeScript: 10000, sieveExtensions: ['fileinto'], notificationMethods: [], externalLists: [] };
|
||||
const mockClient = {
|
||||
getSieveCapabilities: () => caps,
|
||||
getSieveScripts: async () => [],
|
||||
};
|
||||
await useFilterStore.getState().fetchFilters(mockClient as any);
|
||||
expect(useFilterStore.getState().sieveCapabilities).toEqual(caps);
|
||||
});
|
||||
|
||||
it('should set rawScript from script content', async () => {
|
||||
const { generateScript } = await import('@/lib/sieve/generator');
|
||||
const rules = [makeRule()];
|
||||
const script = generateScript(rules);
|
||||
const mockClient = {
|
||||
getSieveCapabilities: () => null,
|
||||
getSieveScripts: async () => [{ id: 's1', name: 'main', blobId: 'b1', isActive: true }],
|
||||
getSieveScriptContent: async () => script,
|
||||
};
|
||||
await useFilterStore.getState().fetchFilters(mockClient as any);
|
||||
expect(useFilterStore.getState().rawScript).toBe(script);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -94,11 +94,9 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
}
|
||||
|
||||
if (activeScriptId) {
|
||||
await client.updateSieveScript(activeScriptId, content);
|
||||
await client.activateSieveScript(activeScriptId);
|
||||
await client.updateSieveScript(activeScriptId, content, true);
|
||||
} else {
|
||||
const script = await client.createSieveScript('filters', content);
|
||||
await client.activateSieveScript(script.id);
|
||||
const script = await client.createSieveScript('filters', content, true);
|
||||
set({ activeScriptId: script.id });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user