刘光辉
6 小时以前 cf11e63b659afcc9792ab3e395f1b45d844431a8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h, reactive } from 'vue';
 
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 
import { workflowFileSubmitPendingKey } from '#/components/Jnpf/upload/src/filePermissionContext';
 
import { refreshConfiguredFilePermissions, useFilePermissions } from './useFilePermissions';
 
const mocks = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock('#/api/workFlow/task', () => ({ getFlowFilePermissions: mocks.get }));
const view = (taskId = 'task', revision = 'v1') => ({ enabled: true, taskId, formId: 'form', status: 'ready', revision, fields: {} });
const wrappers: ReturnType<typeof mount>[] = [];
function setup(initial = view()) {
  const config = reactive({ id: initial.taskId, formId: 'form', filePermissions: initial });
  const submission = reactive({ pending: false });
  let permissions!: ReturnType<typeof useFilePermissions>;
  const wrapper = mount(
    defineComponent({
      setup() {
        permissions = useFilePermissions(() => config);
        return () => h('div');
      },
    }),
    { global: { provide: { [workflowFileSubmitPendingKey as symbol]: () => submission.pending } } },
  );
  wrappers.push(wrapper);
  return { config, permissions, wrapper, submission };
}
beforeEach(() => {
  vi.useFakeTimers();
  mocks.get.mockReset();
});
afterEach(() => {
  wrappers.splice(0).forEach((w) => w.unmount());
  vi.useRealTimers();
});
describe('flow file permissions refresh', () => {
  it('updates configured permissions immediately after workflow state changes', async () => {
    const config = reactive({ id: 'record', formId: 'form', filePermissions: view() });
    mocks.get.mockResolvedValue({ data: view('task', 'started') });
 
    await expect(refreshConfiguredFilePermissions(() => config)).resolves.toBe(true);
 
    expect(mocks.get).toHaveBeenCalledWith('task', { formId: 'form' });
    expect(config.filePermissions.revision).toBe('started');
  });
  it('does not apply an immediate refresh response after switching records', async () => {
    let resolve!: (value: any) => void;
    mocks.get.mockReturnValueOnce(
      new Promise((done) => {
        resolve = done;
      }),
    );
    const config = reactive({ id: 'record', formId: 'form', filePermissions: view() });
    const refresh = refreshConfiguredFilePermissions(() => config);
    config.id = 'other-record';
    config.filePermissions = view('other-task', 'current');
    resolve({ data: view('task', 'stale') });
 
    await expect(refresh).resolves.toBe(false);
    expect(config.filePermissions.taskId).toBe('other-task');
    expect(config.filePermissions.revision).toBe('current');
  });
  it('keeps an unsaved form native and applies instance permissions after reopening the saved record', async () => {
    const config = reactive({ id: '', formId: 'form', filePermissions: undefined as ReturnType<typeof view> | undefined });
    let permissions!: ReturnType<typeof useFilePermissions>;
    wrappers.push(
      mount(
        defineComponent({
          setup() {
            permissions = useFilePermissions(() => config);
            return () => h('div');
          },
        }),
      ),
    );
    window.dispatchEvent(new Event('focus'));
    await vi.advanceTimersByTimeAsync(30_000);
    expect(permissions.value).toBeUndefined();
    expect(mocks.get).not.toHaveBeenCalled();
 
    config.id = 'saved-record';
    config.filePermissions = view();
    await flushPromises();
    expect(permissions.value?.enabled).toBe(true);
    mocks.get.mockRejectedValueOnce(new Error('offline'));
    window.dispatchEvent(new Event('focus'));
    await flushPromises();
    expect(mocks.get).toHaveBeenCalledWith('task', { formId: 'form' });
    expect(permissions.value?.status).toBe('error');
    expect(permissions.value?.enabled).toBe(true);
 
    config.id = '';
    config.filePermissions = undefined;
    await flushPromises();
    expect(permissions.value).toBeUndefined();
  });
  it('does not query old workflows or leave listeners after unmount', async () => {
    const state = setup({ ...view(), enabled: false });
    window.dispatchEvent(new Event('focus'));
    await vi.advanceTimersByTimeAsync(30_000);
    expect(mocks.get).not.toHaveBeenCalled();
    state.wrapper.unmount();
    expect(vi.getTimerCount()).toBe(0);
    window.dispatchEvent(new Event('focus'));
    expect(mocks.get).not.toHaveBeenCalled();
  });
  it('fails closed on request failure and recovers on the next focus', async () => {
    const state = setup();
    mocks.get.mockRejectedValueOnce(new Error('offline'));
    window.dispatchEvent(new Event('focus'));
    await flushPromises();
    expect(state.permissions.value?.status).toBe('error');
    mocks.get.mockResolvedValue({ data: view('task', 'v2') });
    window.dispatchEvent(new Event('focus'));
    await flushPromises();
    expect(state.permissions.value?.revision).toBe('v2');
    expect(state.permissions.value?.status).toBe('ready');
  });
  it('ignores an old response after a form switch and prevents duplicate in-flight requests', async () => {
    let resolve!: (value: any) => void;
    mocks.get.mockReturnValueOnce(
      new Promise((done) => {
        resolve = done;
      }),
    );
    const state = setup();
    window.dispatchEvent(new Event('focus'));
    window.dispatchEvent(new Event('focus'));
    expect(mocks.get).toHaveBeenCalledTimes(1);
    state.config.id = 'other';
    state.config.filePermissions = view('other');
    await flushPromises();
    mocks.get.mockResolvedValue({ data: view('other', 'new') });
    window.dispatchEvent(new Event('focus'));
    await flushPromises();
    resolve({ data: view('task', 'stale') });
    await flushPromises();
    expect(state.permissions.value?.taskId).toBe('other');
    expect(state.permissions.value?.revision).toBe('new');
  });
  it('rejects another workflow response and controlled-to-legacy downgrade', async () => {
    const state = setup();
    mocks.get.mockResolvedValue({ data: view('other') });
    window.dispatchEvent(new Event('focus'));
    await flushPromises();
    expect(state.permissions.value?.status).toBe('error');
    mocks.get.mockResolvedValue({ data: { ...view(), enabled: false } });
    window.dispatchEvent(new Event('focus'));
    await flushPromises();
    expect(state.permissions.value?.status).toBe('error');
    expect(state.permissions.value?.enabled).toBe(true);
  });
  it('refreshes using the resolved task id rather than the native record alias', async () => {
    const state = setup();
    state.config.id = 'business-record';
    await flushPromises();
    mocks.get.mockResolvedValue({ data: view() });
    window.dispatchEvent(new Event('focus'));
    await flushPromises();
    expect(mocks.get).toHaveBeenCalledWith('task', { formId: 'form' });
    expect(state.permissions.value?.status).toBe('ready');
  });
  it('fences in-flight permissions while approval submits and refreshes after failure recovery', async () => {
    const state = setup();
    let reject!: (error: Error) => void;
    mocks.get.mockReturnValueOnce(
      new Promise((_resolve, fail) => {
        reject = fail;
      }),
    );
    window.dispatchEvent(new Event('focus'));
    state.submission.pending = true;
    await vi.advanceTimersByTimeAsync(30_000);
    window.dispatchEvent(new Event('focus'));
    expect(mocks.get).toHaveBeenCalledTimes(1);
    mocks.get.mockResolvedValue({ data: view('task', 'next-node') });
    state.submission.pending = false;
    await flushPromises();
    expect(state.permissions.value?.revision).toBe('next-node');
    reject(new Error('stale transition failure'));
    await flushPromises();
    expect(state.permissions.value?.status).toBe('ready');
    expect(state.permissions.value?.revision).toBe('next-node');
  });
});