import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
appendRepeatRow,
|
deleteRepeatRow,
|
fetchFillData,
|
FillDataError,
|
parseFillData,
|
previewSourceSync,
|
restoreRepeatRow,
|
synchronizeSource,
|
} from '../../src/api/record-fill';
|
|
describe('record fill api', () => {
|
afterEach(() => vi.unstubAllGlobals());
|
|
it('sends the opaque ticket in the Authorization header', async () => {
|
const fetchMock = vi.fn().mockResolvedValue(new Response('{"fields":{}}', { status: 200 }));
|
vi.stubGlobal('fetch', fetchMock);
|
await fetchFillData({ apiBaseUrl: 'https://api.example.com', ticket: 'secret' });
|
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/eln/onlyoffice/recordFill/data', {
|
headers: { Authorization: 'Ticket secret' },
|
});
|
});
|
|
it.each([
|
[401, '填写凭证已过期,请重新打开文档'],
|
[403, '无权读取填写数据'],
|
[404, '填写数据不存在'],
|
[500, '填写数据加载失败'],
|
])('maps HTTP %s', async (status, message) => {
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status })));
|
await expect(fetchFillData({ apiBaseUrl: 'https://api.example.com', ticket: 'secret' })).rejects.toEqual(new FillDataError(message, status));
|
});
|
|
it('normalizes null checkbox to false and validates field values', () => {
|
expect(parseFillData({ fields: { 'eln.field.ok': {
|
formulaMessage: '缺少输入', formulaStatus: 'MISSING_INPUT', required: true, type: 'checkbox', value: null,
|
} } }).fields['eln.field.ok']).toEqual({
|
formulaMessage: '缺少输入',
|
formulaStatus: 'MISSING_INPUT',
|
required: true,
|
type: 'checkbox',
|
value: false,
|
});
|
expect(() => parseFillData({ fields: { bad: { type: 'unknown', value: '' } } })).toThrow('填写数据格式无效');
|
});
|
|
it('parses checkbox groups for editable scalar and consistent repeat fields', () => {
|
const parsed = parseFillData({
|
fields: {
|
'eln.field.comment': { type: 'text', value: '' },
|
'eln.field.passed': {
|
checkboxGroupCode: 'inspection_result', checkboxGroupMaxSelected: 2, checkboxGroupMinSelected: 1, type: 'checkbox', value: true,
|
},
|
'eln.repeat.results.row-1.qualified': {
|
checkboxGroupCode: 'row_result', checkboxGroupMaxSelected: null, checkboxGroupMinSelected: 0, type: 'checkbox', value: true,
|
},
|
'eln.repeat.results.row-2.qualified': {
|
checkboxGroupCode: 'row_result', checkboxGroupMaxSelected: null, checkboxGroupMinSelected: 0, type: 'checkbox', value: false,
|
},
|
},
|
});
|
|
expect(parsed.fields['eln.field.passed']?.checkboxGroupCode).toBe('inspection_result');
|
expect(parsed.fields['eln.field.passed']?.checkboxGroupMinSelected).toBe(1);
|
expect(parsed.fields['eln.field.passed']?.checkboxGroupMaxSelected).toBe(2);
|
expect(parsed.fields['eln.repeat.results.row-1.qualified']?.checkboxGroupCode).toBe('row_result');
|
expect(parsed.fields['eln.repeat.results.row-1.qualified']?.checkboxGroupMaxSelected).toBeNull();
|
expect(parsed.fields['eln.field.comment']).not.toHaveProperty('checkboxGroupCode');
|
});
|
|
it.each([
|
['非法编码', { 'eln.field.passed': { checkboxGroupCode: '结果组', type: 'checkbox', value: true } }],
|
['非复选框', { 'eln.field.comment': { checkboxGroupCode: 'inspection_result', type: 'text', value: '' } }],
|
['只读复选框', { 'eln.field.source': { checkboxGroupCode: 'inspection_result', readOnly: true, type: 'checkbox', value: false } }],
|
['引用复选框', { 'eln.ref.passed.copy-1': { checkboxGroupCode: 'inspection_result', type: 'checkbox', value: false } }],
|
['重复列组不一致', {
|
'eln.repeat.results.row-1.qualified': { checkboxGroupCode: 'row_result', type: 'checkbox', value: true },
|
'eln.repeat.results.row-2.qualified': { checkboxGroupCode: 'other_result', type: 'checkbox', value: false },
|
}],
|
['重复列部分缺组', {
|
'eln.repeat.results.row-1.qualified': { checkboxGroupCode: 'row_result', type: 'checkbox', value: true },
|
'eln.repeat.results.row-2.qualified': { type: 'checkbox', value: false },
|
}],
|
['最少数为负数', {
|
'eln.field.passed': { checkboxGroupCode: 'inspection_result', checkboxGroupMaxSelected: 2, checkboxGroupMinSelected: -1, type: 'checkbox', value: true },
|
}],
|
['最多数为零', {
|
'eln.field.passed': { checkboxGroupCode: 'inspection_result', checkboxGroupMaxSelected: 0, checkboxGroupMinSelected: 0, type: 'checkbox', value: true },
|
}],
|
['最少数超过最多数', {
|
'eln.field.passed': { checkboxGroupCode: 'inspection_result', checkboxGroupMaxSelected: 1, checkboxGroupMinSelected: 2, type: 'checkbox', value: true },
|
}],
|
['未分组字段携带数量规则', {
|
'eln.field.passed': { checkboxGroupMaxSelected: 2, checkboxGroupMinSelected: 0, type: 'checkbox', value: true },
|
}],
|
['重复列数量规则不一致', {
|
'eln.repeat.results.row-1.qualified': { checkboxGroupCode: 'row_result', checkboxGroupMaxSelected: 2, checkboxGroupMinSelected: 0, type: 'checkbox', value: true },
|
'eln.repeat.results.row-2.qualified': { checkboxGroupCode: 'row_result', checkboxGroupMaxSelected: 3, checkboxGroupMinSelected: 0, type: 'checkbox', value: false },
|
}],
|
])('rejects invalid checkbox group response: %s', (_caseName, fields) => {
|
expect(() => parseFillData({ fields })).toThrow('填写数据格式无效');
|
});
|
|
it('parses readonly cross-template field metadata', () => {
|
expect(parseFillData({
|
fields: {
|
'eln.field.source_result': {
|
readOnly: true,
|
type: 'text',
|
value: '合格',
|
valueSource: 'CROSS_TEMPLATE',
|
},
|
},
|
}).fields['eln.field.source_result']).toEqual({
|
readOnly: true,
|
required: false,
|
type: 'text',
|
value: '合格',
|
valueSource: 'CROSS_TEMPLATE',
|
});
|
});
|
|
it('parses pending source synchronization from fill data', () => {
|
const pendingSourceSync = {
|
changedCount: 1, changedFieldCodes: ['source'], differences: [],
|
operationCode: 'source-sync-1', recalculatedCount: 0,
|
recordId: 'record-1', recordVersion: 5, revisionId: 'revision-1',
|
};
|
|
expect(parseFillData({ fields: {}, pendingSourceSync }).pendingSourceSync)
|
.toEqual(pendingSourceSync);
|
});
|
|
it('parses repeat group limits and pending delete metadata', () => {
|
const parsed = parseFillData({
|
fields: { 'eln.repeat.results.row-1.value': { type: 'text', value: '待恢复' } },
|
repeatGroups: [{
|
activeRowCount: 1,
|
checkboxGroups: { qualified: 'row_result' },
|
groupCode: 'results',
|
maxRows: 3,
|
minRows: 1,
|
rows: [{
|
deleteOperationCode: 'delete-1',
|
deleteReason: '录入错误',
|
groupCode: 'results',
|
rowId: 'row-1',
|
rowOrder: 1,
|
status: 'DELETE_PREPARED',
|
}],
|
}],
|
});
|
|
expect(parsed.repeatGroups[0]).toMatchObject({
|
activeRowCount: 1,
|
checkboxGroups: { qualified: 'row_result' },
|
minRows: 1,
|
maxRows: 3,
|
});
|
expect(parsed.repeatGroups[0]?.rows[0]).toMatchObject({
|
deleteOperationCode: 'delete-1', deleteReason: '录入错误', status: 'DELETE_PREPARED',
|
});
|
});
|
|
it('parses step structure and ignores legacy stage metadata', () => {
|
const parsed = parseFillData({
|
fields: {},
|
repeatGroups: [],
|
stepStructure: {
|
configuration: {
|
singleStep: false,
|
stages: [{ id: 'stage-prepare', name: '准备阶段', order: 1 }],
|
steps: [
|
{ id: 'step-prepare', stageId: 'stage-prepare', name: '样品准备', order: 1 },
|
{ id: 'step-test', name: '检验', order: 2 },
|
],
|
},
|
controlSteps: { 'eln.field.sample_name': 'step-prepare' },
|
repeatGroupSteps: { results: 'step-test' },
|
},
|
});
|
|
expect(parsed.stepStructure).toEqual({
|
configuration: {
|
singleStep: false,
|
steps: [
|
{ id: 'step-prepare', name: '样品准备', order: 1 },
|
{ id: 'step-test', name: '检验', order: 2 },
|
],
|
},
|
controlSteps: { 'eln.field.sample_name': 'step-prepare' },
|
repeatGroupSteps: { results: 'step-test' },
|
});
|
});
|
|
it('submits repeat group and operation code with the opaque ticket', async () => {
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
groupCode: 'results', rowId: 'row-1', rowOrder: 1, status: 'PREPARED',
|
}), { status: 200 }));
|
vi.stubGlobal('fetch', fetchMock);
|
|
await expect(appendRepeatRow(
|
{ apiBaseUrl: 'https://api.example.com', ticket: 'secret' },
|
{ groupCode: 'results', operationCode: 'operation-1' },
|
)).resolves.toMatchObject({ rowId: 'row-1', status: 'PREPARED' });
|
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/eln/onlyoffice/recordFill/repeatRows', {
|
body: JSON.stringify({ groupCode: 'results', operationCode: 'operation-1' }),
|
headers: { Authorization: 'Ticket secret', 'Content-Type': 'application/json' },
|
method: 'POST',
|
});
|
});
|
|
it('previews and confirms source sync with only the opaque fill ticket', async () => {
|
const preview = {
|
changedCount: 1,
|
differences: [{
|
bindingId: 'binding-source', changed: true, currentValue: 'GYDW', dataType: 'TEXT',
|
fieldCode: 'source', fieldName: '来源', previousValue: null,
|
sourceProperty: 'quality_standard.source', sourceType: 'LIMS_TASK',
|
}],
|
previewToken: 'preview-token', recordId: 'record-1', recordVersion: 4, revisionId: 'revision-1',
|
};
|
const synchronized = {
|
changedCount: preview.changedCount,
|
changedFieldCodes: ['source'],
|
differences: preview.differences,
|
operationCode: 'source-sync-1',
|
recalculatedCount: 0,
|
recordId: preview.recordId,
|
recordVersion: preview.recordVersion,
|
revisionId: preview.revisionId,
|
};
|
const fetchMock = vi.fn()
|
.mockResolvedValueOnce(new Response(JSON.stringify(preview), { status: 200 }))
|
.mockResolvedValueOnce(new Response(JSON.stringify(synchronized), { status: 200 }));
|
vi.stubGlobal('fetch', fetchMock);
|
const context = { apiBaseUrl: 'https://api.example.com', ticket: 'secret' };
|
|
await expect(previewSourceSync(context)).resolves.toEqual(preview);
|
await expect(synchronizeSource(context, {
|
expectedRecordVersion: 4, operationCode: 'source-sync-1', previewToken: 'preview-token',
|
})).resolves.toEqual(synchronized);
|
|
expect(fetchMock).toHaveBeenNthCalledWith(1,
|
'https://api.example.com/api/eln/onlyoffice/recordFill/sourceSync/preview', {
|
headers: { Authorization: 'Ticket secret' }, method: 'POST',
|
});
|
expect(fetchMock).toHaveBeenNthCalledWith(2,
|
'https://api.example.com/api/eln/onlyoffice/recordFill/sourceSync', {
|
body: JSON.stringify({ expectedRecordVersion: 4, operationCode: 'source-sync-1', previewToken: 'preview-token' }),
|
headers: { Authorization: 'Ticket secret', 'Content-Type': 'application/json' }, method: 'POST',
|
});
|
});
|
|
it('parses cross-template source lineage in sync differences', async () => {
|
const preview = {
|
changedCount: 1,
|
differences: [{
|
bindingId: 'binding-source', changed: true, currentValue: '复测合格', dataType: 'TEXT',
|
fieldCode: 'source_result', fieldName: '源检验结果', previousValue: '合格',
|
sourceFieldVersionId: 'field-version-2', sourceFetchedAt: '2026-09-02T08:30:00Z',
|
sourceProperty: null, sourceRecordId: 'source-record-1',
|
sourceRevisionId: 'source-revision-2', sourceType: 'CROSS_TEMPLATE', sourceValueId: 'source-value-2',
|
}],
|
previewToken: 'preview-token', recordId: 'record-1', recordVersion: 4, revisionId: 'revision-1',
|
};
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(preview), { status: 200 })));
|
|
await expect(previewSourceSync({ apiBaseUrl: 'https://api.example.com', ticket: 'secret' })).resolves.toEqual(preview);
|
});
|
|
it('registers and restores repeat row deletion with the same operation code', async () => {
|
const response = {
|
deleteOperationCode: 'delete-1', deleteReason: '录入错误', groupCode: 'results',
|
rowId: 'row-1', rowOrder: 1, status: 'DELETE_PREPARED',
|
};
|
const fetchMock = vi.fn().mockImplementation(async () => new Response(JSON.stringify(response), { status: 200 }));
|
vi.stubGlobal('fetch', fetchMock);
|
const context = { apiBaseUrl: 'https://api.example.com', ticket: 'secret' };
|
|
await deleteRepeatRow(context, {
|
groupCode: 'results', operationCode: 'delete-1', reason: '录入错误', rowId: 'row-1',
|
});
|
await restoreRepeatRow(context, { groupCode: 'results', operationCode: 'delete-1', rowId: 'row-1' });
|
|
expect(fetchMock).toHaveBeenNthCalledWith(1,
|
'https://api.example.com/api/eln/onlyoffice/recordFill/repeatRows/delete', expect.objectContaining({ method: 'POST' }));
|
expect(fetchMock).toHaveBeenNthCalledWith(2,
|
'https://api.example.com/api/eln/onlyoffice/recordFill/repeatRows/restore', expect.objectContaining({ method: 'POST' }));
|
});
|
});
|