Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccc4fb3ae0 | ||
|
|
4320503209 | ||
|
|
054d3f0da5 | ||
|
|
39ad9d1569 | ||
|
|
079620714e | ||
|
|
89e4261883 | ||
|
|
2efdb7b887 | ||
|
|
d7b8db2afc | ||
|
|
933bc72fd7 | ||
|
|
3b8650eb6b | ||
|
|
a26881cb24 | ||
|
|
80f11471ae | ||
|
|
95a2b3d088 | ||
|
|
fbbff7f5c2 | ||
|
|
c12f0d16bb | ||
|
|
82220a645c | ||
|
|
5638dcc7ad | ||
|
|
5067e4f255 | ||
|
|
46e0548731 | ||
|
|
e228b802c5 | ||
|
|
b0bef1a120 | ||
|
|
98b1ec0d29 | ||
|
|
a954bd0616 | ||
|
|
78ca5ad142 | ||
|
|
9f24cff9dd | ||
|
|
9bb5670711 | ||
|
|
e6545f2727 | ||
|
|
a913cf231f | ||
|
|
ee4041a526 | ||
|
|
eb5a444d3d | ||
|
|
3cd57bfb60 | ||
|
|
32401de4df | ||
|
|
dd237f129d | ||
|
|
46c9a59a31 | ||
|
|
cdfabec7a4 | ||
|
|
9a51d2da57 | ||
|
|
c086aa107c | ||
|
|
acbeb04edc | ||
|
|
ae17537470 | ||
|
|
114f17f1ca | ||
|
|
a377352a9e | ||
|
|
aae330627d | ||
|
|
4ea2d31a79 | ||
|
|
69eab28da1 | ||
|
|
b3e5d45755 | ||
|
|
9812dc2cb2 | ||
|
|
c474177a16 | ||
|
|
df2f1eb028 | ||
|
|
f41344e694 | ||
|
|
8cf1da96f5 | ||
|
|
142f650d73 | ||
|
|
78d47fa28f | ||
|
|
ab3a933e11 | ||
|
|
99117314bf | ||
|
|
2088660f55 |
@@ -6,7 +6,7 @@ cd web && pnpm install
|
||||
pipx install uv
|
||||
|
||||
echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc
|
||||
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor\"" >> ~/.bashrc
|
||||
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention\"" >> ~/.bashrc
|
||||
echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev\"" >> ~/.bashrc
|
||||
echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc
|
||||
echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc
|
||||
|
||||
+7
-1
@@ -6,6 +6,12 @@
|
||||
|
||||
* @crazywoola @laipz8200 @Yeuoly
|
||||
|
||||
# CODEOWNERS file
|
||||
.github/CODEOWNERS @laipz8200 @crazywoola
|
||||
|
||||
# Docs
|
||||
docs/ @crazywoola
|
||||
|
||||
# Backend (default owner, more specific rules below will override)
|
||||
api/ @QuantumGhost
|
||||
|
||||
@@ -116,7 +122,7 @@ api/controllers/console/feature.py @GarfieldDai @GareArc
|
||||
api/controllers/web/feature.py @GarfieldDai @GareArc
|
||||
|
||||
# Backend - Database Migrations
|
||||
api/migrations/ @snakevash @laipz8200
|
||||
api/migrations/ @snakevash @laipz8200 @MRZHUH
|
||||
|
||||
# Frontend
|
||||
web/ @iamjoel
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/package.json
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
- name: Web dependencies
|
||||
working-directory: ./web
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/package.json
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
- name: Web dependencies
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/package.json
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.FILES_CHANGED == 'true'
|
||||
|
||||
+342
-16
@@ -13,6 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./web
|
||||
|
||||
steps:
|
||||
@@ -21,14 +22,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: web/**
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
package_json_file: web/package.json
|
||||
@@ -36,23 +30,355 @@ jobs:
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/package.json
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
- name: Restore Jest cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/.cache/jest
|
||||
key: ${{ runner.os }}-jest-${{ hashFiles('web/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-jest-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check i18n types synchronization
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm run check:i18n-types
|
||||
|
||||
- name: Run tests
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ./web
|
||||
run: pnpm test
|
||||
run: |
|
||||
pnpm exec jest \
|
||||
--ci \
|
||||
--maxWorkers=100% \
|
||||
--coverage \
|
||||
--passWithNoTests
|
||||
|
||||
- name: Coverage Summary
|
||||
if: always()
|
||||
id: coverage-summary
|
||||
run: |
|
||||
set -eo pipefail
|
||||
|
||||
COVERAGE_FILE="coverage/coverage-final.json"
|
||||
COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json"
|
||||
|
||||
if [ ! -f "$COVERAGE_FILE" ] && [ ! -f "$COVERAGE_SUMMARY_FILE" ]; then
|
||||
echo "has_coverage=false" >> "$GITHUB_OUTPUT"
|
||||
echo "### 🚨 Test Coverage Report :test_tube:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Coverage data not found. Ensure Jest runs with coverage enabled." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "has_coverage=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
node <<'NODE' >> "$GITHUB_STEP_SUMMARY"
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
let libCoverage = null;
|
||||
|
||||
try {
|
||||
libCoverage = require('istanbul-lib-coverage');
|
||||
} catch (error) {
|
||||
libCoverage = null;
|
||||
}
|
||||
|
||||
const summaryPath = path.join('coverage', 'coverage-summary.json');
|
||||
const finalPath = path.join('coverage', 'coverage-final.json');
|
||||
|
||||
const hasSummary = fs.existsSync(summaryPath);
|
||||
const hasFinal = fs.existsSync(finalPath);
|
||||
|
||||
if (!hasSummary && !hasFinal) {
|
||||
console.log('### Test Coverage Summary :test_tube:');
|
||||
console.log('');
|
||||
console.log('No coverage data found.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const summary = hasSummary
|
||||
? JSON.parse(fs.readFileSync(summaryPath, 'utf8'))
|
||||
: null;
|
||||
const coverage = hasFinal
|
||||
? JSON.parse(fs.readFileSync(finalPath, 'utf8'))
|
||||
: null;
|
||||
|
||||
const getLineCoverageFromStatements = (statementMap, statementHits) => {
|
||||
const lineHits = {};
|
||||
|
||||
if (!statementMap || !statementHits) {
|
||||
return lineHits;
|
||||
}
|
||||
|
||||
Object.entries(statementMap).forEach(([key, statement]) => {
|
||||
const line = statement?.start?.line;
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
const hits = statementHits[key] ?? 0;
|
||||
const previous = lineHits[line];
|
||||
lineHits[line] = previous === undefined ? hits : Math.max(previous, hits);
|
||||
});
|
||||
|
||||
return lineHits;
|
||||
};
|
||||
|
||||
const getFileCoverage = (entry) => (
|
||||
libCoverage ? libCoverage.createFileCoverage(entry) : null
|
||||
);
|
||||
|
||||
const getLineHits = (entry, fileCoverage) => {
|
||||
const lineHits = entry.l ?? {};
|
||||
if (Object.keys(lineHits).length > 0) {
|
||||
return lineHits;
|
||||
}
|
||||
if (fileCoverage) {
|
||||
return fileCoverage.getLineCoverage();
|
||||
}
|
||||
return getLineCoverageFromStatements(entry.statementMap ?? {}, entry.s ?? {});
|
||||
};
|
||||
|
||||
const getUncoveredLines = (entry, fileCoverage, lineHits) => {
|
||||
if (lineHits && Object.keys(lineHits).length > 0) {
|
||||
return Object.entries(lineHits)
|
||||
.filter(([, count]) => count === 0)
|
||||
.map(([line]) => Number(line))
|
||||
.sort((a, b) => a - b);
|
||||
}
|
||||
if (fileCoverage) {
|
||||
return fileCoverage.getUncoveredLines();
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const totals = {
|
||||
lines: { covered: 0, total: 0 },
|
||||
statements: { covered: 0, total: 0 },
|
||||
branches: { covered: 0, total: 0 },
|
||||
functions: { covered: 0, total: 0 },
|
||||
};
|
||||
const fileSummaries = [];
|
||||
|
||||
if (summary) {
|
||||
const totalEntry = summary.total ?? {};
|
||||
['lines', 'statements', 'branches', 'functions'].forEach((key) => {
|
||||
if (totalEntry[key]) {
|
||||
totals[key].covered = totalEntry[key].covered ?? 0;
|
||||
totals[key].total = totalEntry[key].total ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(summary)
|
||||
.filter(([file]) => file !== 'total')
|
||||
.forEach(([file, data]) => {
|
||||
fileSummaries.push({
|
||||
file,
|
||||
pct: data.lines?.pct ?? data.statements?.pct ?? 0,
|
||||
lines: {
|
||||
covered: data.lines?.covered ?? 0,
|
||||
total: data.lines?.total ?? 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
} else if (coverage) {
|
||||
Object.entries(coverage).forEach(([file, entry]) => {
|
||||
const fileCoverage = getFileCoverage(entry);
|
||||
const lineHits = getLineHits(entry, fileCoverage);
|
||||
const statementHits = entry.s ?? {};
|
||||
const branchHits = entry.b ?? {};
|
||||
const functionHits = entry.f ?? {};
|
||||
|
||||
const lineTotal = Object.keys(lineHits).length;
|
||||
const lineCovered = Object.values(lineHits).filter((n) => n > 0).length;
|
||||
|
||||
const statementTotal = Object.keys(statementHits).length;
|
||||
const statementCovered = Object.values(statementHits).filter((n) => n > 0).length;
|
||||
|
||||
const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0);
|
||||
const branchCovered = Object.values(branchHits).reduce(
|
||||
(acc, branches) => acc + branches.filter((n) => n > 0).length,
|
||||
0,
|
||||
);
|
||||
|
||||
const functionTotal = Object.keys(functionHits).length;
|
||||
const functionCovered = Object.values(functionHits).filter((n) => n > 0).length;
|
||||
|
||||
totals.lines.total += lineTotal;
|
||||
totals.lines.covered += lineCovered;
|
||||
totals.statements.total += statementTotal;
|
||||
totals.statements.covered += statementCovered;
|
||||
totals.branches.total += branchTotal;
|
||||
totals.branches.covered += branchCovered;
|
||||
totals.functions.total += functionTotal;
|
||||
totals.functions.covered += functionCovered;
|
||||
|
||||
const pct = (covered, tot) => (tot > 0 ? (covered / tot) * 100 : 0);
|
||||
|
||||
fileSummaries.push({
|
||||
file,
|
||||
pct: pct(lineCovered || statementCovered, lineTotal || statementTotal),
|
||||
lines: {
|
||||
covered: lineCovered || statementCovered,
|
||||
total: lineTotal || statementTotal,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const pct = (covered, tot) => (tot > 0 ? ((covered / tot) * 100).toFixed(2) : '0.00');
|
||||
|
||||
console.log('### Test Coverage Summary :test_tube:');
|
||||
console.log('');
|
||||
console.log('| Metric | Coverage | Covered / Total |');
|
||||
console.log('|--------|----------|-----------------|');
|
||||
console.log(`| Lines | ${pct(totals.lines.covered, totals.lines.total)}% | ${totals.lines.covered} / ${totals.lines.total} |`);
|
||||
console.log(`| Statements | ${pct(totals.statements.covered, totals.statements.total)}% | ${totals.statements.covered} / ${totals.statements.total} |`);
|
||||
console.log(`| Branches | ${pct(totals.branches.covered, totals.branches.total)}% | ${totals.branches.covered} / ${totals.branches.total} |`);
|
||||
console.log(`| Functions | ${pct(totals.functions.covered, totals.functions.total)}% | ${totals.functions.covered} / ${totals.functions.total} |`);
|
||||
|
||||
console.log('');
|
||||
console.log('<details><summary>File coverage (lowest lines first)</summary>');
|
||||
console.log('');
|
||||
console.log('```');
|
||||
fileSummaries
|
||||
.sort((a, b) => (a.pct - b.pct) || (b.lines.total - a.lines.total))
|
||||
.slice(0, 25)
|
||||
.forEach(({ file, pct, lines }) => {
|
||||
console.log(`${pct.toFixed(2)}%\t${lines.covered}/${lines.total}\t${file}`);
|
||||
});
|
||||
console.log('```');
|
||||
console.log('</details>');
|
||||
|
||||
if (coverage) {
|
||||
const pctValue = (covered, tot) => {
|
||||
if (tot === 0) {
|
||||
return '0';
|
||||
}
|
||||
return ((covered / tot) * 100)
|
||||
.toFixed(2)
|
||||
.replace(/\.?0+$/, '');
|
||||
};
|
||||
|
||||
const formatLineRanges = (lines) => {
|
||||
if (lines.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const ranges = [];
|
||||
let start = lines[0];
|
||||
let end = lines[0];
|
||||
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
const current = lines[i];
|
||||
if (current === end + 1) {
|
||||
end = current;
|
||||
continue;
|
||||
}
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`);
|
||||
start = current;
|
||||
end = current;
|
||||
}
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`);
|
||||
return ranges.join(',');
|
||||
};
|
||||
|
||||
const tableTotals = {
|
||||
statements: { covered: 0, total: 0 },
|
||||
branches: { covered: 0, total: 0 },
|
||||
functions: { covered: 0, total: 0 },
|
||||
lines: { covered: 0, total: 0 },
|
||||
};
|
||||
const tableRows = Object.entries(coverage)
|
||||
.map(([file, entry]) => {
|
||||
const fileCoverage = getFileCoverage(entry);
|
||||
const lineHits = getLineHits(entry, fileCoverage);
|
||||
const statementHits = entry.s ?? {};
|
||||
const branchHits = entry.b ?? {};
|
||||
const functionHits = entry.f ?? {};
|
||||
|
||||
const lineTotal = Object.keys(lineHits).length;
|
||||
const lineCovered = Object.values(lineHits).filter((n) => n > 0).length;
|
||||
const statementTotal = Object.keys(statementHits).length;
|
||||
const statementCovered = Object.values(statementHits).filter((n) => n > 0).length;
|
||||
const branchTotal = Object.values(branchHits).reduce((acc, branches) => acc + branches.length, 0);
|
||||
const branchCovered = Object.values(branchHits).reduce(
|
||||
(acc, branches) => acc + branches.filter((n) => n > 0).length,
|
||||
0,
|
||||
);
|
||||
const functionTotal = Object.keys(functionHits).length;
|
||||
const functionCovered = Object.values(functionHits).filter((n) => n > 0).length;
|
||||
|
||||
tableTotals.lines.total += lineTotal;
|
||||
tableTotals.lines.covered += lineCovered;
|
||||
tableTotals.statements.total += statementTotal;
|
||||
tableTotals.statements.covered += statementCovered;
|
||||
tableTotals.branches.total += branchTotal;
|
||||
tableTotals.branches.covered += branchCovered;
|
||||
tableTotals.functions.total += functionTotal;
|
||||
tableTotals.functions.covered += functionCovered;
|
||||
|
||||
const uncoveredLines = getUncoveredLines(entry, fileCoverage, lineHits);
|
||||
|
||||
const filePath = entry.path ?? file;
|
||||
const relativePath = path.isAbsolute(filePath)
|
||||
? path.relative(process.cwd(), filePath)
|
||||
: filePath;
|
||||
|
||||
return {
|
||||
file: relativePath || file,
|
||||
statements: pctValue(statementCovered, statementTotal),
|
||||
branches: pctValue(branchCovered, branchTotal),
|
||||
functions: pctValue(functionCovered, functionTotal),
|
||||
lines: pctValue(lineCovered, lineTotal),
|
||||
uncovered: formatLineRanges(uncoveredLines),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.file.localeCompare(b.file));
|
||||
|
||||
const columns = [
|
||||
{ key: 'file', header: 'File', align: 'left' },
|
||||
{ key: 'statements', header: '% Stmts', align: 'right' },
|
||||
{ key: 'branches', header: '% Branch', align: 'right' },
|
||||
{ key: 'functions', header: '% Funcs', align: 'right' },
|
||||
{ key: 'lines', header: '% Lines', align: 'right' },
|
||||
{ key: 'uncovered', header: 'Uncovered Line #s', align: 'left' },
|
||||
];
|
||||
|
||||
const allFilesRow = {
|
||||
file: 'All files',
|
||||
statements: pctValue(tableTotals.statements.covered, tableTotals.statements.total),
|
||||
branches: pctValue(tableTotals.branches.covered, tableTotals.branches.total),
|
||||
functions: pctValue(tableTotals.functions.covered, tableTotals.functions.total),
|
||||
lines: pctValue(tableTotals.lines.covered, tableTotals.lines.total),
|
||||
uncovered: '',
|
||||
};
|
||||
|
||||
const rowsForOutput = [allFilesRow, ...tableRows];
|
||||
const formatRow = (row) => `| ${columns
|
||||
.map(({ key }) => String(row[key] ?? ''))
|
||||
.join(' | ')} |`;
|
||||
const headerRow = `| ${columns.map(({ header }) => header).join(' | ')} |`;
|
||||
const dividerRow = `| ${columns
|
||||
.map(({ align }) => (align === 'right' ? '---:' : ':---'))
|
||||
.join(' | ')} |`;
|
||||
|
||||
console.log('');
|
||||
console.log('<details><summary>Jest coverage table</summary>');
|
||||
console.log('');
|
||||
console.log(headerRow);
|
||||
console.log(dividerRow);
|
||||
rowsForOutput.forEach((row) => console.log(formatRow(row)));
|
||||
console.log('</details>');
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Upload Coverage Artifact
|
||||
if: steps.coverage-summary.outputs.has_coverage == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-coverage-report
|
||||
path: web/coverage
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
Vendored
+1
-1
@@ -37,7 +37,7 @@
|
||||
"-c",
|
||||
"1",
|
||||
"-Q",
|
||||
"dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor",
|
||||
"dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention",
|
||||
"--loglevel",
|
||||
"INFO"
|
||||
],
|
||||
|
||||
@@ -690,3 +690,8 @@ ANNOTATION_IMPORT_RATE_LIMIT_PER_MINUTE=5
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
|
||||
# Maximum number of concurrent annotation import tasks per tenant
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT=5
|
||||
|
||||
# Sandbox expired records clean configuration
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@
|
||||
1. If you need to handle and debug the async tasks (e.g. dataset importing and documents indexing), please start the worker service.
|
||||
|
||||
```bash
|
||||
uv run celery -A app.celery worker -P threads -c 2 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor
|
||||
uv run celery -A app.celery worker -P threads -c 2 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention
|
||||
```
|
||||
|
||||
Additionally, if you want to debug the celery scheduled tasks, you can run the following command in another terminal to start the beat service:
|
||||
|
||||
@@ -218,7 +218,7 @@ class PluginConfig(BaseSettings):
|
||||
|
||||
PLUGIN_DAEMON_TIMEOUT: PositiveFloat | None = Field(
|
||||
description="Timeout in seconds for requests to the plugin daemon (set to None to disable)",
|
||||
default=300.0,
|
||||
default=600.0,
|
||||
)
|
||||
|
||||
INNER_API_KEY_FOR_PLUGIN: str = Field(description="Inner api key for plugin", default="inner-api-key")
|
||||
@@ -1270,6 +1270,21 @@ class TenantIsolatedTaskQueueConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class SandboxExpiredRecordsCleanConfig(BaseSettings):
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD: NonNegativeInt = Field(
|
||||
description="Graceful period in days for sandbox records clean after subscription expiration",
|
||||
default=21,
|
||||
)
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: PositiveInt = Field(
|
||||
description="Maximum number of records to process in each batch",
|
||||
default=1000,
|
||||
)
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: PositiveInt = Field(
|
||||
description="Retention days for sandbox expired workflow_run records and message records",
|
||||
default=30,
|
||||
)
|
||||
|
||||
|
||||
class FeatureConfig(
|
||||
# place the configs in alphabet order
|
||||
AppExecutionConfig,
|
||||
@@ -1295,6 +1310,7 @@ class FeatureConfig(
|
||||
PositionConfig,
|
||||
RagEtlConfig,
|
||||
RepositoryConfig,
|
||||
SandboxExpiredRecordsCleanConfig,
|
||||
SecurityConfig,
|
||||
TenantIsolatedTaskQueueConfig,
|
||||
ToolConfig,
|
||||
|
||||
@@ -146,7 +146,7 @@ class DatasetUpdatePayload(BaseModel):
|
||||
embedding_model: str | None = None
|
||||
embedding_model_provider: str | None = None
|
||||
retrieval_model: dict[str, Any] | None = None
|
||||
partial_member_list: list[str] | None = None
|
||||
partial_member_list: list[dict[str, str]] | None = None
|
||||
external_retrieval_model: dict[str, Any] | None = None
|
||||
external_knowledge_id: str | None = None
|
||||
external_knowledge_api_id: str | None = None
|
||||
|
||||
@@ -40,7 +40,7 @@ from .. import console_ns
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompletionMessagePayload(BaseModel):
|
||||
class CompletionMessageExplorePayload(BaseModel):
|
||||
inputs: dict[str, Any]
|
||||
query: str = ""
|
||||
files: list[dict[str, Any]] | None = None
|
||||
@@ -71,7 +71,7 @@ class ChatMessagePayload(BaseModel):
|
||||
raise ValueError("must be a valid UUID") from exc
|
||||
|
||||
|
||||
register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)
|
||||
register_schema_models(console_ns, CompletionMessageExplorePayload, ChatMessagePayload)
|
||||
|
||||
|
||||
# define completion api for user
|
||||
@@ -80,13 +80,13 @@ register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)
|
||||
endpoint="installed_app_completion",
|
||||
)
|
||||
class CompletionApi(InstalledAppResource):
|
||||
@console_ns.expect(console_ns.models[CompletionMessagePayload.__name__])
|
||||
@console_ns.expect(console_ns.models[CompletionMessageExplorePayload.__name__])
|
||||
def post(self, installed_app):
|
||||
app_model = installed_app.app
|
||||
if app_model.mode != AppMode.COMPLETION:
|
||||
raise NotCompletionAppError()
|
||||
|
||||
payload = CompletionMessagePayload.model_validate(console_ns.payload or {})
|
||||
payload = CompletionMessageExplorePayload.model_validate(console_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
streaming = payload.response_mode == "streaming"
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
from typing import Literal
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource, marshal_with, reqparse
|
||||
from flask_restx import Resource, marshal_with
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
from fields.tag_fields import dataset_tag_fields
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.model import Tag
|
||||
from services.tag_service import TagService
|
||||
|
||||
|
||||
def _validate_name(name):
|
||||
if not name or len(name) < 1 or len(name) > 50:
|
||||
raise ValueError("Name must be between 1 to 50 characters.")
|
||||
return name
|
||||
class TagBasePayload(BaseModel):
|
||||
name: str = Field(description="Tag name", min_length=1, max_length=50)
|
||||
type: Literal["knowledge", "app"] | None = Field(default=None, description="Tag type")
|
||||
|
||||
|
||||
parser_tags = (
|
||||
reqparse.RequestParser()
|
||||
.add_argument(
|
||||
"name",
|
||||
nullable=False,
|
||||
required=True,
|
||||
help="Name must be between 1 to 50 characters.",
|
||||
type=_validate_name,
|
||||
)
|
||||
.add_argument("type", type=str, location="json", choices=Tag.TAG_TYPE_LIST, nullable=True, help="Invalid tag type.")
|
||||
class TagBindingPayload(BaseModel):
|
||||
tag_ids: list[str] = Field(description="Tag IDs to bind")
|
||||
target_id: str = Field(description="Target ID to bind tags to")
|
||||
type: Literal["knowledge", "app"] | None = Field(default=None, description="Tag type")
|
||||
|
||||
|
||||
class TagBindingRemovePayload(BaseModel):
|
||||
tag_id: str = Field(description="Tag ID to remove")
|
||||
target_id: str = Field(description="Target ID to unbind tag from")
|
||||
type: Literal["knowledge", "app"] | None = Field(default=None, description="Tag type")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
TagBasePayload,
|
||||
TagBindingPayload,
|
||||
TagBindingRemovePayload,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,7 +52,7 @@ class TagListApi(Resource):
|
||||
|
||||
return tags, 200
|
||||
|
||||
@console_ns.expect(parser_tags)
|
||||
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@@ -53,22 +62,17 @@ class TagListApi(Resource):
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
args = parser_tags.parse_args()
|
||||
tag = TagService.save_tags(args)
|
||||
payload = TagBasePayload.model_validate(console_ns.payload or {})
|
||||
tag = TagService.save_tags(payload.model_dump())
|
||||
|
||||
response = {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}
|
||||
|
||||
return response, 200
|
||||
|
||||
|
||||
parser_tag_id = reqparse.RequestParser().add_argument(
|
||||
"name", nullable=False, required=True, help="Name must be between 1 to 50 characters.", type=_validate_name
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/tags/<uuid:tag_id>")
|
||||
class TagUpdateDeleteApi(Resource):
|
||||
@console_ns.expect(parser_tag_id)
|
||||
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@@ -79,8 +83,8 @@ class TagUpdateDeleteApi(Resource):
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
args = parser_tag_id.parse_args()
|
||||
tag = TagService.update_tags(args, tag_id)
|
||||
payload = TagBasePayload.model_validate(console_ns.payload or {})
|
||||
tag = TagService.update_tags(payload.model_dump(), tag_id)
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id)
|
||||
|
||||
@@ -100,17 +104,9 @@ class TagUpdateDeleteApi(Resource):
|
||||
return 204
|
||||
|
||||
|
||||
parser_create = (
|
||||
reqparse.RequestParser()
|
||||
.add_argument("tag_ids", type=list, nullable=False, required=True, location="json", help="Tag IDs is required.")
|
||||
.add_argument("target_id", type=str, nullable=False, required=True, location="json", help="Target ID is required.")
|
||||
.add_argument("type", type=str, location="json", choices=Tag.TAG_TYPE_LIST, nullable=True, help="Invalid tag type.")
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/tag-bindings/create")
|
||||
class TagBindingCreateApi(Resource):
|
||||
@console_ns.expect(parser_create)
|
||||
@console_ns.expect(console_ns.models[TagBindingPayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@@ -120,23 +116,15 @@ class TagBindingCreateApi(Resource):
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
args = parser_create.parse_args()
|
||||
TagService.save_tag_binding(args)
|
||||
payload = TagBindingPayload.model_validate(console_ns.payload or {})
|
||||
TagService.save_tag_binding(payload.model_dump())
|
||||
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
parser_remove = (
|
||||
reqparse.RequestParser()
|
||||
.add_argument("tag_id", type=str, nullable=False, required=True, help="Tag ID is required.")
|
||||
.add_argument("target_id", type=str, nullable=False, required=True, help="Target ID is required.")
|
||||
.add_argument("type", type=str, location="json", choices=Tag.TAG_TYPE_LIST, nullable=True, help="Invalid tag type.")
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/tag-bindings/remove")
|
||||
class TagBindingDeleteApi(Resource):
|
||||
@console_ns.expect(parser_remove)
|
||||
@console_ns.expect(console_ns.models[TagBindingRemovePayload.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@@ -146,7 +134,7 @@ class TagBindingDeleteApi(Resource):
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
args = parser_remove.parse_args()
|
||||
TagService.delete_tag_binding(args)
|
||||
payload = TagBindingRemovePayload.model_validate(console_ns.payload or {})
|
||||
TagService.delete_tag_binding(payload.model_dump())
|
||||
|
||||
return {"result": "success"}, 200
|
||||
|
||||
@@ -49,7 +49,7 @@ class DatasetUpdatePayload(BaseModel):
|
||||
embedding_model: str | None = None
|
||||
embedding_model_provider: str | None = None
|
||||
retrieval_model: RetrievalModel | None = None
|
||||
partial_member_list: list[str] | None = None
|
||||
partial_member_list: list[dict[str, str]] | None = None
|
||||
external_retrieval_model: dict[str, Any] | None = None
|
||||
external_knowledge_id: str | None = None
|
||||
external_knowledge_api_id: str | None = None
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
|
||||
from flask import request
|
||||
from flask_restx import fields, marshal_with, reqparse
|
||||
from flask_restx import fields, marshal_with
|
||||
from pydantic import BaseModel, field_validator
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
import services
|
||||
@@ -20,6 +21,7 @@ from controllers.web.error import (
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from core.model_runtime.errors.invoke import InvokeError
|
||||
from libs.helper import uuid_value
|
||||
from models.model import App
|
||||
from services.audio_service import AudioService
|
||||
from services.errors.audio import (
|
||||
@@ -29,6 +31,25 @@ from services.errors.audio import (
|
||||
UnsupportedAudioTypeServiceError,
|
||||
)
|
||||
|
||||
from ..common.schema import register_schema_models
|
||||
|
||||
|
||||
class TextToAudioPayload(BaseModel):
|
||||
message_id: str | None = None
|
||||
voice: str | None = None
|
||||
text: str | None = None
|
||||
streaming: bool | None = None
|
||||
|
||||
@field_validator("message_id")
|
||||
@classmethod
|
||||
def validate_message_id(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
register_schema_models(web_ns, TextToAudioPayload)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -88,6 +109,7 @@ class AudioApi(WebApiResource):
|
||||
|
||||
@web_ns.route("/text-to-audio")
|
||||
class TextApi(WebApiResource):
|
||||
@web_ns.expect(web_ns.models[TextToAudioPayload.__name__])
|
||||
@web_ns.doc("Text to Audio")
|
||||
@web_ns.doc(description="Convert text to audio using text-to-speech service.")
|
||||
@web_ns.doc(
|
||||
@@ -102,18 +124,11 @@ class TextApi(WebApiResource):
|
||||
def post(self, app_model: App, end_user):
|
||||
"""Convert text to audio"""
|
||||
try:
|
||||
parser = (
|
||||
reqparse.RequestParser()
|
||||
.add_argument("message_id", type=str, required=False, location="json")
|
||||
.add_argument("voice", type=str, location="json")
|
||||
.add_argument("text", type=str, location="json")
|
||||
.add_argument("streaming", type=bool, location="json")
|
||||
)
|
||||
args = parser.parse_args()
|
||||
payload = TextToAudioPayload.model_validate(web_ns.payload or {})
|
||||
|
||||
message_id = args.get("message_id", None)
|
||||
text = args.get("text", None)
|
||||
voice = args.get("voice", None)
|
||||
message_id = payload.message_id
|
||||
text = payload.text
|
||||
voice = payload.voice
|
||||
response = AudioService.transcript_tts(
|
||||
app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask_restx import reqparse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import (
|
||||
AppUnavailableError,
|
||||
@@ -34,25 +36,44 @@ from services.errors.llm import InvokeRateLimitError
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompletionMessagePayload(BaseModel):
|
||||
inputs: dict[str, Any] = Field(description="Input variables for the completion")
|
||||
query: str = Field(default="", description="Query text for completion")
|
||||
files: list[dict[str, Any]] | None = Field(default=None, description="Files to be processed")
|
||||
response_mode: Literal["blocking", "streaming"] | None = Field(
|
||||
default=None, description="Response mode: blocking or streaming"
|
||||
)
|
||||
retriever_from: str = Field(default="web_app", description="Source of retriever")
|
||||
|
||||
|
||||
class ChatMessagePayload(BaseModel):
|
||||
inputs: dict[str, Any] = Field(description="Input variables for the chat")
|
||||
query: str = Field(description="User query/message")
|
||||
files: list[dict[str, Any]] | None = Field(default=None, description="Files to be processed")
|
||||
response_mode: Literal["blocking", "streaming"] | None = Field(
|
||||
default=None, description="Response mode: blocking or streaming"
|
||||
)
|
||||
conversation_id: str | None = Field(default=None, description="Conversation ID")
|
||||
parent_message_id: str | None = Field(default=None, description="Parent message ID")
|
||||
retriever_from: str = Field(default="web_app", description="Source of retriever")
|
||||
|
||||
@field_validator("conversation_id", "parent_message_id")
|
||||
@classmethod
|
||||
def validate_uuid(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
register_schema_models(web_ns, CompletionMessagePayload, ChatMessagePayload)
|
||||
|
||||
|
||||
# define completion api for user
|
||||
@web_ns.route("/completion-messages")
|
||||
class CompletionApi(WebApiResource):
|
||||
@web_ns.doc("Create Completion Message")
|
||||
@web_ns.doc(description="Create a completion message for text generation applications.")
|
||||
@web_ns.doc(
|
||||
params={
|
||||
"inputs": {"description": "Input variables for the completion", "type": "object", "required": True},
|
||||
"query": {"description": "Query text for completion", "type": "string", "required": False},
|
||||
"files": {"description": "Files to be processed", "type": "array", "required": False},
|
||||
"response_mode": {
|
||||
"description": "Response mode: blocking or streaming",
|
||||
"type": "string",
|
||||
"enum": ["blocking", "streaming"],
|
||||
"required": False,
|
||||
},
|
||||
"retriever_from": {"description": "Source of retriever", "type": "string", "required": False},
|
||||
}
|
||||
)
|
||||
@web_ns.expect(web_ns.models[CompletionMessagePayload.__name__])
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
@@ -67,18 +88,10 @@ class CompletionApi(WebApiResource):
|
||||
if app_model.mode != AppMode.COMPLETION:
|
||||
raise NotCompletionAppError()
|
||||
|
||||
parser = (
|
||||
reqparse.RequestParser()
|
||||
.add_argument("inputs", type=dict, required=True, location="json")
|
||||
.add_argument("query", type=str, location="json", default="")
|
||||
.add_argument("files", type=list, required=False, location="json")
|
||||
.add_argument("response_mode", type=str, choices=["blocking", "streaming"], location="json")
|
||||
.add_argument("retriever_from", type=str, required=False, default="web_app", location="json")
|
||||
)
|
||||
payload = CompletionMessagePayload.model_validate(web_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
streaming = args["response_mode"] == "streaming"
|
||||
streaming = payload.response_mode == "streaming"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
try:
|
||||
@@ -142,22 +155,7 @@ class CompletionStopApi(WebApiResource):
|
||||
class ChatApi(WebApiResource):
|
||||
@web_ns.doc("Create Chat Message")
|
||||
@web_ns.doc(description="Create a chat message for conversational applications.")
|
||||
@web_ns.doc(
|
||||
params={
|
||||
"inputs": {"description": "Input variables for the chat", "type": "object", "required": True},
|
||||
"query": {"description": "User query/message", "type": "string", "required": True},
|
||||
"files": {"description": "Files to be processed", "type": "array", "required": False},
|
||||
"response_mode": {
|
||||
"description": "Response mode: blocking or streaming",
|
||||
"type": "string",
|
||||
"enum": ["blocking", "streaming"],
|
||||
"required": False,
|
||||
},
|
||||
"conversation_id": {"description": "Conversation UUID", "type": "string", "required": False},
|
||||
"parent_message_id": {"description": "Parent message UUID", "type": "string", "required": False},
|
||||
"retriever_from": {"description": "Source of retriever", "type": "string", "required": False},
|
||||
}
|
||||
)
|
||||
@web_ns.expect(web_ns.models[ChatMessagePayload.__name__])
|
||||
@web_ns.doc(
|
||||
responses={
|
||||
200: "Success",
|
||||
@@ -173,20 +171,10 @@ class ChatApi(WebApiResource):
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
parser = (
|
||||
reqparse.RequestParser()
|
||||
.add_argument("inputs", type=dict, required=True, location="json")
|
||||
.add_argument("query", type=str, required=True, location="json")
|
||||
.add_argument("files", type=list, required=False, location="json")
|
||||
.add_argument("response_mode", type=str, choices=["blocking", "streaming"], location="json")
|
||||
.add_argument("conversation_id", type=uuid_value, location="json")
|
||||
.add_argument("parent_message_id", type=uuid_value, required=False, location="json")
|
||||
.add_argument("retriever_from", type=str, required=False, default="web_app", location="json")
|
||||
)
|
||||
payload = ChatMessagePayload.model_validate(web_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
streaming = args["response_mode"] == "streaming"
|
||||
streaming = payload.response_mode == "streaming"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from enum import StrEnum, auto
|
||||
from typing import Any, Literal
|
||||
@@ -120,7 +121,7 @@ class VariableEntity(BaseModel):
|
||||
allowed_file_types: Sequence[FileType] | None = Field(default_factory=list)
|
||||
allowed_file_extensions: Sequence[str] | None = Field(default_factory=list)
|
||||
allowed_file_upload_methods: Sequence[FileTransferMethod] | None = Field(default_factory=list)
|
||||
json_schema: dict[str, Any] | None = Field(default=None)
|
||||
json_schema: str | None = Field(default=None)
|
||||
|
||||
@field_validator("description", mode="before")
|
||||
@classmethod
|
||||
@@ -134,11 +135,17 @@ class VariableEntity(BaseModel):
|
||||
|
||||
@field_validator("json_schema")
|
||||
@classmethod
|
||||
def validate_json_schema(cls, schema: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
def validate_json_schema(cls, schema: str | None) -> str | None:
|
||||
if schema is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
Draft7Validator.check_schema(schema)
|
||||
json_schema = json.loads(schema)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"invalid json_schema value {schema}")
|
||||
|
||||
try:
|
||||
Draft7Validator.check_schema(json_schema)
|
||||
except SchemaError as e:
|
||||
raise ValueError(f"Invalid JSON schema: {e.message}")
|
||||
return schema
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Union, final
|
||||
|
||||
@@ -175,6 +176,13 @@ class BaseAppGenerator:
|
||||
value = True
|
||||
elif value == 0:
|
||||
value = False
|
||||
case VariableEntityType.JSON_OBJECT:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{variable_entity.variable} in input form must be a string")
|
||||
try:
|
||||
json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object")
|
||||
case _:
|
||||
raise AssertionError("this statement should be unreachable.")
|
||||
|
||||
|
||||
@@ -342,9 +342,11 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
|
||||
self._task_state.llm_result.message.content = current_content
|
||||
|
||||
if isinstance(event, QueueLLMChunkEvent):
|
||||
event_type = self._message_cycle_manager.get_message_event_type(message_id=self._message_id)
|
||||
yield self._message_cycle_manager.message_to_stream_response(
|
||||
answer=cast(str, delta_text),
|
||||
message_id=self._message_id,
|
||||
event_type=event_type,
|
||||
)
|
||||
else:
|
||||
yield self._agent_message_to_stream_response(
|
||||
|
||||
@@ -5,7 +5,7 @@ from threading import Thread
|
||||
from typing import Union
|
||||
|
||||
from flask import Flask, current_app
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
@@ -54,6 +54,20 @@ class MessageCycleManager:
|
||||
):
|
||||
self._application_generate_entity = application_generate_entity
|
||||
self._task_state = task_state
|
||||
self._message_has_file: set[str] = set()
|
||||
|
||||
def get_message_event_type(self, message_id: str) -> StreamEvent:
|
||||
if message_id in self._message_has_file:
|
||||
return StreamEvent.MESSAGE_FILE
|
||||
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
has_file = session.query(exists().where(MessageFile.message_id == message_id)).scalar()
|
||||
|
||||
if has_file:
|
||||
self._message_has_file.add(message_id)
|
||||
return StreamEvent.MESSAGE_FILE
|
||||
|
||||
return StreamEvent.MESSAGE
|
||||
|
||||
def generate_conversation_name(self, *, conversation_id: str, query: str) -> Thread | None:
|
||||
"""
|
||||
@@ -214,7 +228,11 @@ class MessageCycleManager:
|
||||
return None
|
||||
|
||||
def message_to_stream_response(
|
||||
self, answer: str, message_id: str, from_variable_selector: list[str] | None = None
|
||||
self,
|
||||
answer: str,
|
||||
message_id: str,
|
||||
from_variable_selector: list[str] | None = None,
|
||||
event_type: StreamEvent | None = None,
|
||||
) -> MessageStreamResponse:
|
||||
"""
|
||||
Message to stream response.
|
||||
@@ -222,16 +240,12 @@ class MessageCycleManager:
|
||||
:param message_id: message id
|
||||
:return:
|
||||
"""
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
message_file = session.scalar(select(MessageFile).where(MessageFile.id == message_id))
|
||||
event_type = StreamEvent.MESSAGE_FILE if message_file else StreamEvent.MESSAGE
|
||||
|
||||
return MessageStreamResponse(
|
||||
task_id=self._application_generate_entity.task_id,
|
||||
id=message_id,
|
||||
answer=answer,
|
||||
from_variable_selector=from_variable_selector,
|
||||
event=event_type,
|
||||
event=event_type or StreamEvent.MESSAGE,
|
||||
)
|
||||
|
||||
def message_replace_to_stream_response(self, answer: str, reason: str = "") -> MessageReplaceStreamResponse:
|
||||
|
||||
@@ -18,34 +18,20 @@ This module provides the interface for invoking and authenticating various model
|
||||
|
||||
- Model provider display
|
||||
|
||||

|
||||
|
||||
Displays a list of all supported providers, including provider names, icons, supported model types list, predefined model list, configuration method, and credentials form rules, etc. For detailed rule design, see: [Schema](./docs/en_US/schema.md).
|
||||
Displays a list of all supported providers, including provider names, icons, supported model types list, predefined model list, configuration method, and credentials form rules, etc.
|
||||
|
||||
- Selectable model list display
|
||||
|
||||

|
||||
|
||||
After configuring provider/model credentials, the dropdown (application orchestration interface/default model) allows viewing of the available LLM list. Greyed out items represent predefined model lists from providers without configured credentials, facilitating user review of supported models.
|
||||
|
||||
In addition, this list also returns configurable parameter information and rules for LLM, as shown below:
|
||||
|
||||

|
||||
|
||||
These parameters are all defined in the backend, allowing different settings for various parameters supported by different models, as detailed in: [Schema](./docs/en_US/schema.md#ParameterRule).
|
||||
In addition, this list also returns configurable parameter information and rules for LLM. These parameters are all defined in the backend, allowing different settings for various parameters supported by different models.
|
||||
|
||||
- Provider/model credential authentication
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
The provider list returns configuration information for the credentials form, which can be authenticated through Runtime's interface. The first image above is a provider credential DEMO, and the second is a model credential DEMO.
|
||||
The provider list returns configuration information for the credentials form, which can be authenticated through Runtime's interface.
|
||||
|
||||
## Structure
|
||||
|
||||

|
||||
|
||||
Model Runtime is divided into three layers:
|
||||
|
||||
- The outermost layer is the factory method
|
||||
@@ -60,9 +46,6 @@ Model Runtime is divided into three layers:
|
||||
|
||||
It offers direct invocation of various model types, predefined model configuration information, getting predefined/remote model lists, model credential authentication methods. Different models provide additional special methods, like LLM's pre-computed tokens method, cost information obtaining method, etc., **allowing horizontal expansion** for different models under the same provider (within supported model types).
|
||||
|
||||
## Next Steps
|
||||
## Documentation
|
||||
|
||||
- Add new provider configuration: [Link](./docs/en_US/provider_scale_out.md)
|
||||
- Add new models for existing providers: [Link](./docs/en_US/provider_scale_out.md#AddModel)
|
||||
- View YAML configuration rules: [Link](./docs/en_US/schema.md)
|
||||
- Implement interface methods: [Link](./docs/en_US/interfaces.md)
|
||||
For detailed documentation on how to add new providers or models, please refer to the [Dify documentation](https://docs.dify.ai/).
|
||||
|
||||
@@ -18,34 +18,20 @@
|
||||
|
||||
- 模型供应商展示
|
||||
|
||||

|
||||
|
||||
展示所有已支持的供应商列表,除了返回供应商名称、图标之外,还提供了支持的模型类型列表,预定义模型列表、配置方式以及配置凭据的表单规则等等,规则设计详见:[Schema](./docs/zh_Hans/schema.md)。
|
||||
展示所有已支持的供应商列表,除了返回供应商名称、图标之外,还提供了支持的模型类型列表,预定义模型列表、配置方式以及配置凭据的表单规则等等。
|
||||
|
||||
- 可选择的模型列表展示
|
||||
|
||||

|
||||
配置供应商/模型凭据后,可在此下拉(应用编排界面/默认模型)查看可用的 LLM 列表,其中灰色的为未配置凭据供应商的预定义模型列表,方便用户查看已支持的模型。
|
||||
|
||||
配置供应商/模型凭据后,可在此下拉(应用编排界面/默认模型)查看可用的 LLM 列表,其中灰色的为未配置凭据供应商的预定义模型列表,方便用户查看已支持的模型。
|
||||
|
||||
除此之外,该列表还返回了 LLM 可配置的参数信息和规则,如下图:
|
||||
|
||||

|
||||
|
||||
这里的参数均为后端定义,相比之前只有 5 种固定参数,这里可为不同模型设置所支持的各种参数,详见:[Schema](./docs/zh_Hans/schema.md#ParameterRule)。
|
||||
除此之外,该列表还返回了 LLM 可配置的参数信息和规则。这里的参数均为后端定义,相比之前只有 5 种固定参数,这里可为不同模型设置所支持的各种参数。
|
||||
|
||||
- 供应商/模型凭据鉴权
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
供应商列表返回了凭据表单的配置信息,可通过 Runtime 提供的接口对凭据进行鉴权,上图 1 为供应商凭据 DEMO,上图 2 为模型凭据 DEMO。
|
||||
供应商列表返回了凭据表单的配置信息,可通过 Runtime 提供的接口对凭据进行鉴权。
|
||||
|
||||
## 结构
|
||||
|
||||

|
||||
|
||||
Model Runtime 分三层:
|
||||
|
||||
- 最外层为工厂方法
|
||||
@@ -59,8 +45,7 @@ Model Runtime 分三层:
|
||||
对于供应商/模型凭据,有两种情况
|
||||
|
||||
- 如 OpenAI 这类中心化供应商,需要定义如**api_key**这类的鉴权凭据
|
||||
- 如[**Xinference**](https://github.com/xorbitsai/inference)这类本地部署的供应商,需要定义如**server_url**这类的地址凭据,有时候还需要定义**model_uid**之类的模型类型凭据,就像下面这样,当在供应商层定义了这些凭据后,就可以在前端页面上直接展示,无需修改前端逻辑。
|
||||

|
||||
- 如[**Xinference**](https://github.com/xorbitsai/inference)这类本地部署的供应商,需要定义如**server_url**这类的地址凭据,有时候还需要定义**model_uid**之类的模型类型凭据。当在供应商层定义了这些凭据后,就可以在前端页面上直接展示,无需修改前端逻辑。
|
||||
|
||||
当配置好凭据后,就可以通过 DifyRuntime 的外部接口直接获取到对应供应商所需要的**Schema**(凭据表单规则),从而在可以在不修改前端逻辑的情况下,提供新的供应商/模型的支持。
|
||||
|
||||
@@ -74,20 +59,6 @@ Model Runtime 分三层:
|
||||
|
||||
- 模型凭据 (**在供应商层定义**):这是一类不经常变动,一般在配置好后就不会再变动的参数,如 **api_key**、**server_url** 等。在 DifyRuntime 中,他们的参数名一般为**credentials: dict[str, any]**,Provider 层的 credentials 会直接被传递到这一层,不需要再单独定义。
|
||||
|
||||
## 下一步
|
||||
## 文档
|
||||
|
||||
### [增加新的供应商配置 👈🏻](./docs/zh_Hans/provider_scale_out.md)
|
||||
|
||||
当添加后,这里将会出现一个新的供应商
|
||||
|
||||

|
||||
|
||||
### [为已存在的供应商新增模型 👈🏻](./docs/zh_Hans/provider_scale_out.md#%E5%A2%9E%E5%8A%A0%E6%A8%A1%E5%9E%8B)
|
||||
|
||||
当添加后,对应供应商的模型列表中将会出现一个新的预定义模型供用户选择,如 GPT-3.5 GPT-4 ChatGLM3-6b 等,而对于支持自定义模型的供应商,则不需要新增模型。
|
||||
|
||||

|
||||
|
||||
### [接口的具体实现 👈🏻](./docs/zh_Hans/interfaces.md)
|
||||
|
||||
你可以在这里找到你想要查看的接口的具体实现,以及接口的参数和返回值的具体含义。
|
||||
有关如何添加新供应商或模型的详细文档,请参阅 [Dify 文档](https://docs.dify.ai/)。
|
||||
|
||||
@@ -39,7 +39,7 @@ from core.trigger.errors import (
|
||||
plugin_daemon_inner_api_baseurl = URL(str(dify_config.PLUGIN_DAEMON_URL))
|
||||
_plugin_daemon_timeout_config = cast(
|
||||
float | httpx.Timeout | None,
|
||||
getattr(dify_config, "PLUGIN_DAEMON_TIMEOUT", 300.0),
|
||||
getattr(dify_config, "PLUGIN_DAEMON_TIMEOUT", 600.0),
|
||||
)
|
||||
plugin_daemon_request_timeout: httpx.Timeout | None
|
||||
if _plugin_daemon_timeout_config is None:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
@@ -52,7 +53,7 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
|
||||
def __init__(self, fixed_separator: str = "\n\n", separators: list[str] | None = None, **kwargs: Any):
|
||||
"""Create a new TextSplitter."""
|
||||
super().__init__(**kwargs)
|
||||
self._fixed_separator = fixed_separator
|
||||
self._fixed_separator = codecs.decode(fixed_separator, "unicode_escape")
|
||||
self._separators = separators or ["\n\n", "\n", "。", ". ", " ", ""]
|
||||
|
||||
def split_text(self, text: str) -> list[str]:
|
||||
@@ -94,7 +95,8 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
|
||||
splits = re.split(r" +", text)
|
||||
else:
|
||||
splits = text.split(separator)
|
||||
splits = [item + separator if i < len(splits) else item for i, item in enumerate(splits)]
|
||||
if self._keep_separator:
|
||||
splits = [s + separator for s in splits[:-1]] + splits[-1:]
|
||||
else:
|
||||
splits = list(text)
|
||||
if separator == "\n":
|
||||
@@ -103,7 +105,7 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
|
||||
splits = [s for s in splits if (s not in {"", "\n"})]
|
||||
_good_splits = []
|
||||
_good_splits_lengths = [] # cache the lengths of the splits
|
||||
_separator = separator if self._keep_separator else ""
|
||||
_separator = "" if self._keep_separator else separator
|
||||
s_lens = self._length_function(splits)
|
||||
if separator != "":
|
||||
for s, s_len in zip(splits, s_lens):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
@@ -42,15 +43,25 @@ class StartNode(Node[StartNodeData]):
|
||||
if value is None and variable.required:
|
||||
raise ValueError(f"{key} is required in input form")
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{key} must be a JSON object")
|
||||
|
||||
schema = variable.json_schema
|
||||
if not schema:
|
||||
continue
|
||||
|
||||
if not value:
|
||||
continue
|
||||
|
||||
try:
|
||||
Draft7Validator(schema).validate(value)
|
||||
json_schema = json.loads(schema)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"{schema} must be a valid JSON object")
|
||||
|
||||
try:
|
||||
json_value = json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"{value} must be a valid JSON object")
|
||||
|
||||
try:
|
||||
Draft7Validator(json_schema).validate(json_value)
|
||||
except ValidationError as e:
|
||||
raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}")
|
||||
node_inputs[key] = value
|
||||
node_inputs[key] = json_value
|
||||
|
||||
@@ -34,10 +34,10 @@ if [[ "${MODE}" == "worker" ]]; then
|
||||
if [[ -z "${CELERY_QUEUES}" ]]; then
|
||||
if [[ "${EDITION}" == "CLOUD" ]]; then
|
||||
# Cloud edition: separate queues for dataset and trigger tasks
|
||||
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
else
|
||||
# Community edition (SELF_HOSTED): dataset, pipeline and workflow have separate queues
|
||||
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
fi
|
||||
else
|
||||
DEFAULT_QUEUES="${CELERY_QUEUES}"
|
||||
@@ -69,6 +69,53 @@ if [[ "${MODE}" == "worker" ]]; then
|
||||
|
||||
elif [[ "${MODE}" == "beat" ]]; then
|
||||
exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO}
|
||||
|
||||
elif [[ "${MODE}" == "job" ]]; then
|
||||
# Job mode: Run a one-time Flask command and exit
|
||||
# Pass Flask command and arguments via container args
|
||||
# Example K8s usage:
|
||||
# args:
|
||||
# - create-tenant
|
||||
# - --email
|
||||
# - [email protected]
|
||||
#
|
||||
# Example Docker usage:
|
||||
# docker run -e MODE=job dify-api:latest create-tenant --email [email protected]
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "Error: No command specified for job mode."
|
||||
echo ""
|
||||
echo "Usage examples:"
|
||||
echo " Kubernetes:"
|
||||
echo " args: [create-tenant, --email, [email protected]]"
|
||||
echo ""
|
||||
echo " Docker:"
|
||||
echo " docker run -e MODE=job dify-api create-tenant --email [email protected]"
|
||||
echo ""
|
||||
echo "Available commands:"
|
||||
echo " create-tenant, reset-password, reset-email, upgrade-db,"
|
||||
echo " vdb-migrate, install-plugins, and more..."
|
||||
echo ""
|
||||
echo "Run 'flask --help' to see all available commands."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running Flask job command: flask $*"
|
||||
|
||||
# Temporarily disable exit on error to capture exit code
|
||||
set +e
|
||||
flask "$@"
|
||||
JOB_EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
if [[ ${JOB_EXIT_CODE} -eq 0 ]]; then
|
||||
echo "Job completed successfully."
|
||||
else
|
||||
echo "Job failed with exit code ${JOB_EXIT_CODE}."
|
||||
fi
|
||||
|
||||
exit ${JOB_EXIT_CODE}
|
||||
|
||||
else
|
||||
if [[ "${DEBUG}" == "true" ]]; then
|
||||
exec flask run --host=${DIFY_BIND_ADDRESS:-0.0.0.0} --port=${DIFY_PORT:-5001} --debug
|
||||
|
||||
@@ -87,15 +87,16 @@ class OpenDALStorage(BaseStorage):
|
||||
if not self.exists(path):
|
||||
raise FileNotFoundError("Path not found")
|
||||
|
||||
all_files = self.op.scan(path=path)
|
||||
# Use the new OpenDAL 0.46.0+ API with recursive listing
|
||||
lister = self.op.list(path, recursive=True)
|
||||
if files and directories:
|
||||
logger.debug("files and directories on %s scanned", path)
|
||||
return [f.path for f in all_files]
|
||||
return [entry.path for entry in lister]
|
||||
if files:
|
||||
logger.debug("files on %s scanned", path)
|
||||
return [f.path for f in all_files if not f.path.endswith("/")]
|
||||
return [entry.path for entry in lister if not entry.metadata.is_dir]
|
||||
elif directories:
|
||||
logger.debug("directories on %s scanned", path)
|
||||
return [f.path for f in all_files if f.path.endswith("/")]
|
||||
return [entry.path for entry in lister if entry.metadata.is_dir]
|
||||
else:
|
||||
raise ValueError("At least one of files or directories must be True")
|
||||
|
||||
@@ -11,6 +11,7 @@ from collections.abc import Generator, Mapping
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Optional, Union, cast
|
||||
from uuid import UUID
|
||||
from zoneinfo import available_timezones
|
||||
|
||||
from flask import Response, stream_with_context
|
||||
@@ -119,6 +120,19 @@ def uuid_value(value: Any) -> str:
|
||||
raise ValueError(error)
|
||||
|
||||
|
||||
def normalize_uuid(value: str | UUID) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
try:
|
||||
return uuid_value(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("must be a valid UUID") from exc
|
||||
|
||||
|
||||
UUIDStrOrEmpty = Annotated[str, AfterValidator(normalize_uuid)]
|
||||
|
||||
|
||||
def alphanumeric(value: str):
|
||||
# check if the value is alphanumeric and underlined
|
||||
if re.match(r"^[a-zA-Z0-9_]+$", value):
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"bs4~=0.0.1",
|
||||
"cachetools~=5.3.0",
|
||||
"celery~=5.5.2",
|
||||
"chardet~=5.1.0",
|
||||
"charset-normalizer>=3.4.4",
|
||||
"flask~=3.1.2",
|
||||
"flask-compress>=1.17,<1.18",
|
||||
"flask-cors~=6.0.0",
|
||||
@@ -32,6 +32,7 @@ dependencies = [
|
||||
"httpx[socks]~=0.27.0",
|
||||
"jieba==0.42.1",
|
||||
"json-repair>=0.41.1",
|
||||
"jsonschema>=4.25.1",
|
||||
"langfuse~=2.51.3",
|
||||
"langsmith~=0.1.77",
|
||||
"markdown~=3.5.1",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
|
||||
from typing_extensions import TypedDict
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
from enums.cloud_plan import CloudPlan
|
||||
@@ -11,6 +15,15 @@ from extensions.ext_redis import redis_client
|
||||
from libs.helper import RateLimiter
|
||||
from models import Account, TenantAccountJoin, TenantAccountRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionPlan(TypedDict):
|
||||
"""Tenant subscriptionplan information."""
|
||||
|
||||
plan: str
|
||||
expiration_date: int
|
||||
|
||||
|
||||
class BillingService:
|
||||
base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL")
|
||||
@@ -239,3 +252,39 @@ class BillingService:
|
||||
def sync_partner_tenants_bindings(cls, account_id: str, partner_key: str, click_id: str):
|
||||
payload = {"account_id": account_id, "click_id": click_id}
|
||||
return cls._send_request("PUT", f"/partners/{partner_key}/tenants", json=payload)
|
||||
|
||||
@classmethod
|
||||
def get_plan_bulk(cls, tenant_ids: Sequence[str]) -> dict[str, SubscriptionPlan]:
|
||||
"""
|
||||
Bulk fetch billing subscription plan via billing API.
|
||||
Payload: {"tenant_ids": ["t1", "t2", ...]} (max 200 per request)
|
||||
Returns:
|
||||
Mapping of tenant_id -> {plan: str, expiration_date: int}
|
||||
"""
|
||||
results: dict[str, SubscriptionPlan] = {}
|
||||
subscription_adapter = TypeAdapter(SubscriptionPlan)
|
||||
|
||||
chunk_size = 200
|
||||
for i in range(0, len(tenant_ids), chunk_size):
|
||||
chunk = tenant_ids[i : i + chunk_size]
|
||||
try:
|
||||
resp = cls._send_request("POST", "/subscription/plan/batch", json={"tenant_ids": chunk})
|
||||
data = resp.get("data", {})
|
||||
|
||||
for tenant_id, plan in data.items():
|
||||
subscription_plan = subscription_adapter.validate_python(plan)
|
||||
results[tenant_id] = subscription_plan
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch billing info batch for tenants: %s", chunk)
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def get_expired_subscription_cleanup_whitelist(cls) -> Sequence[str]:
|
||||
resp = cls._send_request("GET", "/subscription/cleanup/whitelist")
|
||||
data = resp.get("data", [])
|
||||
tenant_whitelist = []
|
||||
for item in data:
|
||||
tenant_whitelist.append(item["tenant_id"])
|
||||
return tenant_whitelist
|
||||
|
||||
@@ -23,7 +23,7 @@ class RagPipelineDatasetCreateEntity(BaseModel):
|
||||
description: str
|
||||
icon_info: IconInfo
|
||||
permission: str
|
||||
partial_member_list: list[str] | None = None
|
||||
partial_member_list: list[dict[str, str]] | None = None
|
||||
yaml_content: str | None = None
|
||||
|
||||
|
||||
|
||||
+9
-18
@@ -2,7 +2,9 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType
|
||||
from models import Account, Tenant
|
||||
from models.tools import ApiToolProvider
|
||||
from services.tools.api_tools_manage_service import ApiToolManageService
|
||||
@@ -298,7 +300,7 @@ class TestApiToolManageService:
|
||||
provider_name = fake.company()
|
||||
icon = {"type": "emoji", "value": "🔧"}
|
||||
credentials = {"auth_type": "none", "api_key_header": "X-API-Key", "api_key_value": ""}
|
||||
schema_type = "openapi"
|
||||
schema_type = ApiProviderSchemaType.OPENAPI
|
||||
schema = self._create_test_openapi_schema()
|
||||
privacy_policy = "https://example.com/privacy"
|
||||
custom_disclaimer = "Custom disclaimer text"
|
||||
@@ -364,7 +366,7 @@ class TestApiToolManageService:
|
||||
provider_name = fake.company()
|
||||
icon = {"type": "emoji", "value": "🔧"}
|
||||
credentials = {"auth_type": "none"}
|
||||
schema_type = "openapi"
|
||||
schema_type = ApiProviderSchemaType.OPENAPI
|
||||
schema = self._create_test_openapi_schema()
|
||||
privacy_policy = "https://example.com/privacy"
|
||||
custom_disclaimer = "Custom disclaimer text"
|
||||
@@ -428,21 +430,10 @@ class TestApiToolManageService:
|
||||
labels = ["test"]
|
||||
|
||||
# Act & Assert: Try to create provider with invalid schema type
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ApiToolManageService.create_api_tool_provider(
|
||||
user_id=account.id,
|
||||
tenant_id=tenant.id,
|
||||
provider_name=provider_name,
|
||||
icon=icon,
|
||||
credentials=credentials,
|
||||
schema_type=schema_type,
|
||||
schema=schema,
|
||||
privacy_policy=privacy_policy,
|
||||
custom_disclaimer=custom_disclaimer,
|
||||
labels=labels,
|
||||
)
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
TypeAdapter(ApiProviderSchemaType).validate_python(schema_type)
|
||||
|
||||
assert "invalid schema type" in str(exc_info.value)
|
||||
assert "validation error" in str(exc_info.value)
|
||||
|
||||
def test_create_api_tool_provider_missing_auth_type(
|
||||
self, flask_req_ctx_with_containers, db_session_with_containers, mock_external_service_dependencies
|
||||
@@ -464,7 +455,7 @@ class TestApiToolManageService:
|
||||
provider_name = fake.company()
|
||||
icon = {"type": "emoji", "value": "🔧"}
|
||||
credentials = {} # Missing auth_type
|
||||
schema_type = "openapi"
|
||||
schema_type = ApiProviderSchemaType.OPENAPI
|
||||
schema = self._create_test_openapi_schema()
|
||||
privacy_policy = "https://example.com/privacy"
|
||||
custom_disclaimer = "Custom disclaimer text"
|
||||
@@ -507,7 +498,7 @@ class TestApiToolManageService:
|
||||
provider_name = fake.company()
|
||||
icon = {"type": "emoji", "value": "🔑"}
|
||||
credentials = {"auth_type": "api_key", "api_key_header": "X-API-Key", "api_key_value": fake.uuid4()}
|
||||
schema_type = "openapi"
|
||||
schema_type = ApiProviderSchemaType.OPENAPI
|
||||
schema = self._create_test_openapi_schema()
|
||||
privacy_policy = "https://example.com/privacy"
|
||||
custom_disclaimer = "Custom disclaimer text"
|
||||
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueAgentMessageEvent,
|
||||
QueueErrorEvent,
|
||||
QueueLLMChunkEvent,
|
||||
QueueMessageEndEvent,
|
||||
QueueMessageFileEvent,
|
||||
QueuePingEvent,
|
||||
)
|
||||
from core.app.entities.task_entities import (
|
||||
EasyUITaskState,
|
||||
ErrorStreamResponse,
|
||||
MessageEndStreamResponse,
|
||||
MessageFileStreamResponse,
|
||||
MessageReplaceStreamResponse,
|
||||
MessageStreamResponse,
|
||||
PingStreamResponse,
|
||||
StreamEvent,
|
||||
)
|
||||
from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline
|
||||
from core.base.tts import AppGeneratorTTSPublisher
|
||||
from core.model_runtime.entities.llm_entities import LLMResult as RuntimeLLMResult
|
||||
from core.model_runtime.entities.message_entities import TextPromptMessageContent
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from models.model import AppMode
|
||||
|
||||
|
||||
class TestEasyUIBasedGenerateTaskPipelineProcessStreamResponse:
|
||||
"""Test cases for EasyUIBasedGenerateTaskPipeline._process_stream_response method."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_application_generate_entity(self):
|
||||
"""Create a mock application generate entity."""
|
||||
entity = Mock(spec=ChatAppGenerateEntity)
|
||||
entity.task_id = "test-task-id"
|
||||
entity.app_id = "test-app-id"
|
||||
# minimal app_config used by pipeline internals
|
||||
entity.app_config = SimpleNamespace(
|
||||
tenant_id="test-tenant-id",
|
||||
app_id="test-app-id",
|
||||
app_mode=AppMode.CHAT,
|
||||
app_model_config_dict={},
|
||||
additional_features=None,
|
||||
sensitive_word_avoidance=None,
|
||||
)
|
||||
# minimal model_conf for LLMResult init
|
||||
entity.model_conf = SimpleNamespace(
|
||||
model="test-model",
|
||||
provider_model_bundle=SimpleNamespace(model_type_instance=Mock()),
|
||||
credentials={},
|
||||
)
|
||||
return entity
|
||||
|
||||
@pytest.fixture
|
||||
def mock_queue_manager(self):
|
||||
"""Create a mock queue manager."""
|
||||
manager = Mock(spec=AppQueueManager)
|
||||
return manager
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message_cycle_manager(self):
|
||||
"""Create a mock message cycle manager."""
|
||||
manager = Mock()
|
||||
manager.get_message_event_type.return_value = StreamEvent.MESSAGE
|
||||
manager.message_to_stream_response.return_value = Mock(spec=MessageStreamResponse)
|
||||
manager.message_file_to_stream_response.return_value = Mock(spec=MessageFileStreamResponse)
|
||||
manager.message_replace_to_stream_response.return_value = Mock(spec=MessageReplaceStreamResponse)
|
||||
manager.handle_retriever_resources = Mock()
|
||||
manager.handle_annotation_reply.return_value = None
|
||||
return manager
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conversation(self):
|
||||
"""Create a mock conversation."""
|
||||
conversation = Mock()
|
||||
conversation.id = "test-conversation-id"
|
||||
conversation.mode = "chat"
|
||||
return conversation
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message(self):
|
||||
"""Create a mock message."""
|
||||
message = Mock()
|
||||
message.id = "test-message-id"
|
||||
message.created_at = Mock()
|
||||
message.created_at.timestamp.return_value = 1234567890
|
||||
return message
|
||||
|
||||
@pytest.fixture
|
||||
def mock_task_state(self):
|
||||
"""Create a mock task state."""
|
||||
task_state = Mock(spec=EasyUITaskState)
|
||||
|
||||
# Create LLM result mock
|
||||
llm_result = Mock(spec=RuntimeLLMResult)
|
||||
llm_result.prompt_messages = []
|
||||
llm_result.message = Mock()
|
||||
llm_result.message.content = ""
|
||||
|
||||
task_state.llm_result = llm_result
|
||||
task_state.answer = ""
|
||||
|
||||
return task_state
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(
|
||||
self,
|
||||
mock_application_generate_entity,
|
||||
mock_queue_manager,
|
||||
mock_conversation,
|
||||
mock_message,
|
||||
mock_message_cycle_manager,
|
||||
mock_task_state,
|
||||
):
|
||||
"""Create an EasyUIBasedGenerateTaskPipeline instance with mocked dependencies."""
|
||||
with patch(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.EasyUITaskState", return_value=mock_task_state
|
||||
):
|
||||
pipeline = EasyUIBasedGenerateTaskPipeline(
|
||||
application_generate_entity=mock_application_generate_entity,
|
||||
queue_manager=mock_queue_manager,
|
||||
conversation=mock_conversation,
|
||||
message=mock_message,
|
||||
stream=True,
|
||||
)
|
||||
pipeline._message_cycle_manager = mock_message_cycle_manager
|
||||
pipeline._task_state = mock_task_state
|
||||
return pipeline
|
||||
|
||||
def test_get_message_event_type_called_once_when_first_llm_chunk_arrives(
|
||||
self, pipeline, mock_message_cycle_manager
|
||||
):
|
||||
"""Expect get_message_event_type to be called when processing the first LLM chunk event."""
|
||||
# Setup a minimal LLM chunk event
|
||||
chunk = Mock()
|
||||
chunk.delta.message.content = "hi"
|
||||
chunk.prompt_messages = []
|
||||
llm_chunk_event = Mock(spec=QueueLLMChunkEvent)
|
||||
llm_chunk_event.chunk = chunk
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = llm_chunk_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
# Execute
|
||||
list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
mock_message_cycle_manager.get_message_event_type.assert_called_once_with(message_id="test-message-id")
|
||||
|
||||
def test_llm_chunk_event_with_text_content(self, pipeline, mock_message_cycle_manager, mock_task_state):
|
||||
"""Test handling of LLM chunk events with text content."""
|
||||
# Setup
|
||||
chunk = Mock()
|
||||
chunk.delta.message.content = "Hello, world!"
|
||||
chunk.prompt_messages = []
|
||||
|
||||
llm_chunk_event = Mock(spec=QueueLLMChunkEvent)
|
||||
llm_chunk_event.chunk = chunk
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = llm_chunk_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
mock_message_cycle_manager.get_message_event_type.return_value = StreamEvent.MESSAGE
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
mock_message_cycle_manager.message_to_stream_response.assert_called_once_with(
|
||||
answer="Hello, world!", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
assert mock_task_state.llm_result.message.content == "Hello, world!"
|
||||
|
||||
def test_llm_chunk_event_with_list_content(self, pipeline, mock_message_cycle_manager, mock_task_state):
|
||||
"""Test handling of LLM chunk events with list content."""
|
||||
# Setup
|
||||
text_content = Mock(spec=TextPromptMessageContent)
|
||||
text_content.data = "Hello"
|
||||
|
||||
chunk = Mock()
|
||||
chunk.delta.message.content = [text_content, " world!"]
|
||||
chunk.prompt_messages = []
|
||||
|
||||
llm_chunk_event = Mock(spec=QueueLLMChunkEvent)
|
||||
llm_chunk_event.chunk = chunk
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = llm_chunk_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
mock_message_cycle_manager.get_message_event_type.return_value = StreamEvent.MESSAGE
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
mock_message_cycle_manager.message_to_stream_response.assert_called_once_with(
|
||||
answer="Hello world!", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
assert mock_task_state.llm_result.message.content == "Hello world!"
|
||||
|
||||
def test_agent_message_event(self, pipeline, mock_message_cycle_manager, mock_task_state):
|
||||
"""Test handling of agent message events."""
|
||||
# Setup
|
||||
chunk = Mock()
|
||||
chunk.delta.message.content = "Agent response"
|
||||
|
||||
agent_message_event = Mock(spec=QueueAgentMessageEvent)
|
||||
agent_message_event.chunk = chunk
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = agent_message_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
# Ensure method under assertion is a mock to track calls
|
||||
pipeline._agent_message_to_stream_response = Mock(return_value=Mock())
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
# Agent messages should use _agent_message_to_stream_response
|
||||
pipeline._agent_message_to_stream_response.assert_called_once_with(
|
||||
answer="Agent response", message_id="test-message-id"
|
||||
)
|
||||
|
||||
def test_message_end_event(self, pipeline, mock_message_cycle_manager, mock_task_state):
|
||||
"""Test handling of message end events."""
|
||||
# Setup
|
||||
llm_result = Mock(spec=RuntimeLLMResult)
|
||||
llm_result.message = Mock()
|
||||
llm_result.message.content = "Final response"
|
||||
|
||||
message_end_event = Mock(spec=QueueMessageEndEvent)
|
||||
message_end_event.llm_result = llm_result
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = message_end_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
pipeline._save_message = Mock()
|
||||
pipeline._message_end_to_stream_response = Mock(return_value=Mock(spec=MessageEndStreamResponse))
|
||||
|
||||
# Patch db.engine used inside pipeline for session creation
|
||||
with patch(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", new=SimpleNamespace(engine=Mock())
|
||||
):
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
assert mock_task_state.llm_result == llm_result
|
||||
pipeline._save_message.assert_called_once()
|
||||
pipeline._message_end_to_stream_response.assert_called_once()
|
||||
|
||||
def test_error_event(self, pipeline):
|
||||
"""Test handling of error events."""
|
||||
# Setup
|
||||
error_event = Mock(spec=QueueErrorEvent)
|
||||
error_event.error = Exception("Test error")
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = error_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
pipeline.handle_error = Mock(return_value=Exception("Test error"))
|
||||
pipeline.error_to_stream_response = Mock(return_value=Mock(spec=ErrorStreamResponse))
|
||||
|
||||
# Patch db.engine used inside pipeline for session creation
|
||||
with patch(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", new=SimpleNamespace(engine=Mock())
|
||||
):
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
pipeline.handle_error.assert_called_once()
|
||||
pipeline.error_to_stream_response.assert_called_once()
|
||||
|
||||
def test_ping_event(self, pipeline):
|
||||
"""Test handling of ping events."""
|
||||
# Setup
|
||||
ping_event = Mock(spec=QueuePingEvent)
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = ping_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
pipeline.ping_stream_response = Mock(return_value=Mock(spec=PingStreamResponse))
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
pipeline.ping_stream_response.assert_called_once()
|
||||
|
||||
def test_file_event(self, pipeline, mock_message_cycle_manager):
|
||||
"""Test handling of file events."""
|
||||
# Setup
|
||||
file_event = Mock(spec=QueueMessageFileEvent)
|
||||
file_event.message_file_id = "file-id"
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = file_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
file_response = Mock(spec=MessageFileStreamResponse)
|
||||
mock_message_cycle_manager.message_file_to_stream_response.return_value = file_response
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 1
|
||||
assert responses[0] == file_response
|
||||
mock_message_cycle_manager.message_file_to_stream_response.assert_called_once_with(file_event)
|
||||
|
||||
def test_publisher_is_called_with_messages(self, pipeline):
|
||||
"""Test that publisher publishes messages when provided."""
|
||||
# Setup
|
||||
publisher = Mock(spec=AppGeneratorTTSPublisher)
|
||||
|
||||
ping_event = Mock(spec=QueuePingEvent)
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = ping_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
pipeline.ping_stream_response = Mock(return_value=Mock(spec=PingStreamResponse))
|
||||
|
||||
# Execute
|
||||
list(pipeline._process_stream_response(publisher=publisher, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
# Called once with message and once with None at the end
|
||||
assert publisher.publish.call_count == 2
|
||||
publisher.publish.assert_any_call(mock_queue_message)
|
||||
publisher.publish.assert_any_call(None)
|
||||
|
||||
def test_trace_manager_passed_to_save_message(self, pipeline):
|
||||
"""Test that trace manager is passed to _save_message."""
|
||||
# Setup
|
||||
trace_manager = Mock(spec=TraceQueueManager)
|
||||
|
||||
message_end_event = Mock(spec=QueueMessageEndEvent)
|
||||
message_end_event.llm_result = None
|
||||
|
||||
mock_queue_message = Mock()
|
||||
mock_queue_message.event = message_end_event
|
||||
pipeline.queue_manager.listen.return_value = [mock_queue_message]
|
||||
|
||||
pipeline._save_message = Mock()
|
||||
pipeline._message_end_to_stream_response = Mock(return_value=Mock(spec=MessageEndStreamResponse))
|
||||
|
||||
# Patch db.engine used inside pipeline for session creation
|
||||
with patch(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db", new=SimpleNamespace(engine=Mock())
|
||||
):
|
||||
# Execute
|
||||
list(pipeline._process_stream_response(publisher=None, trace_manager=trace_manager))
|
||||
|
||||
# Assert
|
||||
pipeline._save_message.assert_called_once_with(session=ANY, trace_manager=trace_manager)
|
||||
|
||||
def test_multiple_events_sequence(self, pipeline, mock_message_cycle_manager, mock_task_state):
|
||||
"""Test handling multiple events in sequence."""
|
||||
# Setup
|
||||
chunk1 = Mock()
|
||||
chunk1.delta.message.content = "Hello"
|
||||
chunk1.prompt_messages = []
|
||||
|
||||
chunk2 = Mock()
|
||||
chunk2.delta.message.content = " world!"
|
||||
chunk2.prompt_messages = []
|
||||
|
||||
llm_chunk_event1 = Mock(spec=QueueLLMChunkEvent)
|
||||
llm_chunk_event1.chunk = chunk1
|
||||
|
||||
ping_event = Mock(spec=QueuePingEvent)
|
||||
|
||||
llm_chunk_event2 = Mock(spec=QueueLLMChunkEvent)
|
||||
llm_chunk_event2.chunk = chunk2
|
||||
|
||||
mock_queue_messages = [
|
||||
Mock(event=llm_chunk_event1),
|
||||
Mock(event=ping_event),
|
||||
Mock(event=llm_chunk_event2),
|
||||
]
|
||||
pipeline.queue_manager.listen.return_value = mock_queue_messages
|
||||
|
||||
mock_message_cycle_manager.get_message_event_type.return_value = StreamEvent.MESSAGE
|
||||
pipeline.ping_stream_response = Mock(return_value=Mock(spec=PingStreamResponse))
|
||||
|
||||
# Execute
|
||||
responses = list(pipeline._process_stream_response(publisher=None, trace_manager=None))
|
||||
|
||||
# Assert
|
||||
assert len(responses) == 3
|
||||
assert mock_task_state.llm_result.message.content == "Hello world!"
|
||||
|
||||
# Verify calls to message_to_stream_response
|
||||
assert mock_message_cycle_manager.message_to_stream_response.call_count == 2
|
||||
mock_message_cycle_manager.message_to_stream_response.assert_any_call(
|
||||
answer="Hello", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
mock_message_cycle_manager.message_to_stream_response.assert_any_call(
|
||||
answer=" world!", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
"""Unit tests for the message cycle manager optimization."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import current_app
|
||||
|
||||
from core.app.entities.task_entities import MessageStreamResponse, StreamEvent
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
|
||||
|
||||
class TestMessageCycleManagerOptimization:
|
||||
"""Test cases for the message cycle manager optimization that prevents N+1 queries."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_application_generate_entity(self):
|
||||
"""Create a mock application generate entity."""
|
||||
entity = Mock()
|
||||
entity.task_id = "test-task-id"
|
||||
return entity
|
||||
|
||||
@pytest.fixture
|
||||
def message_cycle_manager(self, mock_application_generate_entity):
|
||||
"""Create a message cycle manager instance."""
|
||||
task_state = Mock()
|
||||
return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state)
|
||||
|
||||
def test_get_message_event_type_with_message_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE_FILE when message has files."""
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
|
||||
):
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_message_file = Mock()
|
||||
# Current implementation uses session.query(...).scalar()
|
||||
mock_session.query.return_value.scalar.return_value = mock_message_file
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
mock_session.query.return_value.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_without_message_file(self, message_cycle_manager):
|
||||
"""Test get_message_event_type returns MESSAGE when message has no files."""
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
|
||||
):
|
||||
# Setup mock session and no message file
|
||||
mock_session = Mock()
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
# Current implementation uses session.query(...).scalar()
|
||||
mock_session.query.return_value.scalar.return_value = None
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE
|
||||
mock_session.query.return_value.scalar.assert_called_once()
|
||||
|
||||
def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager):
|
||||
"""MessageCycleManager.message_to_stream_response expects a valid event_type; callers should precompute it."""
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
|
||||
):
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_message_file = Mock()
|
||||
# Current implementation uses session.query(...).scalar()
|
||||
mock_session.query.return_value.scalar.return_value = mock_message_file
|
||||
|
||||
# Execute: compute event type once, then pass to message_to_stream_response
|
||||
with current_app.app_context():
|
||||
event_type = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE_FILE
|
||||
mock_session.query.return_value.scalar.assert_called_once()
|
||||
|
||||
def test_message_to_stream_response_with_event_type_skips_query(self, message_cycle_manager):
|
||||
"""Test that message_to_stream_response skips database query when event_type is provided."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class:
|
||||
# Execute with event_type provided
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE
|
||||
# Should not query database when event_type is provided
|
||||
mock_session_class.assert_not_called()
|
||||
|
||||
def test_message_to_stream_response_with_from_variable_selector(self, message_cycle_manager):
|
||||
"""Test message_to_stream_response with from_variable_selector parameter."""
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world",
|
||||
message_id="test-message-id",
|
||||
from_variable_selector=["var1", "var2"],
|
||||
event_type=StreamEvent.MESSAGE,
|
||||
)
|
||||
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.from_variable_selector == ["var1", "var2"]
|
||||
assert result.event == StreamEvent.MESSAGE
|
||||
|
||||
def test_optimization_usage_example(self, message_cycle_manager):
|
||||
"""Test the optimization pattern that should be used by callers."""
|
||||
# Step 1: Get event type once (this queries database)
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db", new=SimpleNamespace(engine=Mock())),
|
||||
):
|
||||
mock_session = Mock()
|
||||
mock_session_class.return_value.__enter__.return_value = mock_session
|
||||
# Current implementation uses session.query(...).scalar()
|
||||
mock_session.query.return_value.scalar.return_value = None # No files
|
||||
with current_app.app_context():
|
||||
event_type = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Should query database once
|
||||
mock_session_class.assert_called_once_with(ANY, expire_on_commit=False)
|
||||
assert event_type == StreamEvent.MESSAGE
|
||||
|
||||
# Step 2: Use event_type for multiple calls (no additional queries)
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_class:
|
||||
mock_session_class.return_value.__enter__.return_value = Mock()
|
||||
|
||||
chunk1_response = message_cycle_manager.message_to_stream_response(
|
||||
answer="Chunk 1", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
|
||||
chunk2_response = message_cycle_manager.message_to_stream_response(
|
||||
answer="Chunk 2", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
|
||||
# Should not query database again
|
||||
mock_session_class.assert_not_called()
|
||||
|
||||
assert chunk1_response.event == StreamEvent.MESSAGE
|
||||
assert chunk2_response.event == StreamEvent.MESSAGE
|
||||
assert chunk1_response.answer == "Chunk 1"
|
||||
assert chunk2_response.answer == "Chunk 2"
|
||||
@@ -901,6 +901,13 @@ class TestFixedRecursiveCharacterTextSplitter:
|
||||
# Verify no empty chunks
|
||||
assert all(len(chunk) > 0 for chunk in result)
|
||||
|
||||
def test_double_slash_n(self):
|
||||
data = "chunk 1\n\nsubchunk 1.\nsubchunk 2.\n\n---\n\nchunk 2\n\nsubchunk 1\nsubchunk 2."
|
||||
separator = "\\n\\n---\\n\\n"
|
||||
splitter = FixedRecursiveCharacterTextSplitter(fixed_separator=separator)
|
||||
chunks = splitter.split_text(data)
|
||||
assert chunks == ["chunk 1\n\nsubchunk 1.\nsubchunk 2.", "chunk 2\n\nsubchunk 1\nsubchunk 2."]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test Metadata Preservation
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
@@ -46,14 +47,16 @@ def make_start_node(user_inputs, variables):
|
||||
|
||||
|
||||
def test_json_object_valid_schema():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age"],
|
||||
}
|
||||
schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age"],
|
||||
}
|
||||
)
|
||||
|
||||
variables = [
|
||||
VariableEntity(
|
||||
@@ -65,7 +68,7 @@ def test_json_object_valid_schema():
|
||||
)
|
||||
]
|
||||
|
||||
user_inputs = {"profile": {"age": 20, "name": "Tom"}}
|
||||
user_inputs = {"profile": json.dumps({"age": 20, "name": "Tom"})}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
result = node._run()
|
||||
@@ -74,12 +77,23 @@ def test_json_object_valid_schema():
|
||||
|
||||
|
||||
def test_json_object_invalid_json_string():
|
||||
schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age", "name"],
|
||||
}
|
||||
)
|
||||
variables = [
|
||||
VariableEntity(
|
||||
variable="profile",
|
||||
label="profile",
|
||||
type=VariableEntityType.JSON_OBJECT,
|
||||
required=True,
|
||||
json_schema=schema,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -88,38 +102,21 @@ def test_json_object_invalid_json_string():
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
with pytest.raises(ValueError, match="profile must be a JSON object"):
|
||||
node._run()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["[1, 2, 3]", "123"])
|
||||
def test_json_object_valid_json_but_not_object(value):
|
||||
variables = [
|
||||
VariableEntity(
|
||||
variable="profile",
|
||||
label="profile",
|
||||
type=VariableEntityType.JSON_OBJECT,
|
||||
required=True,
|
||||
)
|
||||
]
|
||||
|
||||
user_inputs = {"profile": value}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
with pytest.raises(ValueError, match="profile must be a JSON object"):
|
||||
with pytest.raises(ValueError, match='{"age": 20, "name": "Tom" must be a valid JSON object'):
|
||||
node._run()
|
||||
|
||||
|
||||
def test_json_object_does_not_match_schema():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age", "name"],
|
||||
}
|
||||
schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age", "name"],
|
||||
}
|
||||
)
|
||||
|
||||
variables = [
|
||||
VariableEntity(
|
||||
@@ -132,7 +129,7 @@ def test_json_object_does_not_match_schema():
|
||||
]
|
||||
|
||||
# age is a string, which violates the schema (expects number)
|
||||
user_inputs = {"profile": {"age": "twenty", "name": "Tom"}}
|
||||
user_inputs = {"profile": json.dumps({"age": "twenty", "name": "Tom"})}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
@@ -141,14 +138,16 @@ def test_json_object_does_not_match_schema():
|
||||
|
||||
|
||||
def test_json_object_missing_required_schema_field():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age", "name"],
|
||||
}
|
||||
schema = json.dumps(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {"type": "number"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
"required": ["age", "name"],
|
||||
}
|
||||
)
|
||||
|
||||
variables = [
|
||||
VariableEntity(
|
||||
@@ -161,7 +160,7 @@ def test_json_object_missing_required_schema_field():
|
||||
]
|
||||
|
||||
# Missing required field "name"
|
||||
user_inputs = {"profile": {"age": 20}}
|
||||
user_inputs = {"profile": json.dumps({"age": 20})}
|
||||
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
@@ -214,7 +213,7 @@ def test_json_object_optional_variable_not_provided():
|
||||
variable="profile",
|
||||
label="profile",
|
||||
type=VariableEntityType.JSON_OBJECT,
|
||||
required=False,
|
||||
required=True,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -223,5 +222,5 @@ def test_json_object_optional_variable_not_provided():
|
||||
node = make_start_node(user_inputs, variables)
|
||||
|
||||
# Current implementation raises a validation error even when the variable is optional
|
||||
with pytest.raises(ValueError, match="profile must be a JSON object"):
|
||||
with pytest.raises(ValueError, match="profile is required in input form"):
|
||||
node._run()
|
||||
|
||||
@@ -1156,6 +1156,199 @@ class TestBillingServiceEdgeCases:
|
||||
assert "Only team owner or team admin can perform this action" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestBillingServiceSubscriptionOperations:
|
||||
"""Unit tests for subscription operations in BillingService.
|
||||
|
||||
Tests cover:
|
||||
- Bulk plan retrieval with chunking
|
||||
- Expired subscription cleanup whitelist retrieval
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_send_request(self):
|
||||
"""Mock _send_request method."""
|
||||
with patch.object(BillingService, "_send_request") as mock:
|
||||
yield mock
|
||||
|
||||
def test_get_plan_bulk_with_empty_list(self, mock_send_request):
|
||||
"""Test bulk plan retrieval with empty tenant list."""
|
||||
# Arrange
|
||||
tenant_ids = []
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert result == {}
|
||||
mock_send_request.assert_not_called()
|
||||
|
||||
def test_get_plan_bulk_with_chunking(self, mock_send_request):
|
||||
"""Test bulk plan retrieval with more than 200 tenants (chunking logic)."""
|
||||
# Arrange - 250 tenants to test chunking (chunk_size = 200)
|
||||
tenant_ids = [f"tenant-{i}" for i in range(250)]
|
||||
|
||||
# First chunk: tenants 0-199
|
||||
first_chunk_response = {
|
||||
"data": {f"tenant-{i}": {"plan": "sandbox", "expiration_date": 1735689600} for i in range(200)}
|
||||
}
|
||||
|
||||
# Second chunk: tenants 200-249
|
||||
second_chunk_response = {
|
||||
"data": {f"tenant-{i}": {"plan": "professional", "expiration_date": 1767225600} for i in range(200, 250)}
|
||||
}
|
||||
|
||||
mock_send_request.side_effect = [first_chunk_response, second_chunk_response]
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 250
|
||||
assert result["tenant-0"]["plan"] == "sandbox"
|
||||
assert result["tenant-199"]["plan"] == "sandbox"
|
||||
assert result["tenant-200"]["plan"] == "professional"
|
||||
assert result["tenant-249"]["plan"] == "professional"
|
||||
assert mock_send_request.call_count == 2
|
||||
|
||||
# Verify first chunk call
|
||||
first_call = mock_send_request.call_args_list[0]
|
||||
assert first_call[0][0] == "POST"
|
||||
assert first_call[0][1] == "/subscription/plan/batch"
|
||||
assert len(first_call[1]["json"]["tenant_ids"]) == 200
|
||||
|
||||
# Verify second chunk call
|
||||
second_call = mock_send_request.call_args_list[1]
|
||||
assert len(second_call[1]["json"]["tenant_ids"]) == 50
|
||||
|
||||
def test_get_plan_bulk_with_partial_batch_failure(self, mock_send_request):
|
||||
"""Test bulk plan retrieval when one batch fails but others succeed."""
|
||||
# Arrange - 250 tenants, second batch will fail
|
||||
tenant_ids = [f"tenant-{i}" for i in range(250)]
|
||||
|
||||
# First chunk succeeds
|
||||
first_chunk_response = {
|
||||
"data": {f"tenant-{i}": {"plan": "sandbox", "expiration_date": 1735689600} for i in range(200)}
|
||||
}
|
||||
|
||||
# Second chunk fails - need to create a mock that raises when called
|
||||
def side_effect_func(*args, **kwargs):
|
||||
if mock_send_request.call_count == 1:
|
||||
return first_chunk_response
|
||||
else:
|
||||
raise ValueError("API error")
|
||||
|
||||
mock_send_request.side_effect = side_effect_func
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert - should only have data from first batch
|
||||
assert len(result) == 200
|
||||
assert result["tenant-0"]["plan"] == "sandbox"
|
||||
assert result["tenant-199"]["plan"] == "sandbox"
|
||||
assert "tenant-200" not in result
|
||||
assert mock_send_request.call_count == 2
|
||||
|
||||
def test_get_plan_bulk_with_all_batches_failing(self, mock_send_request):
|
||||
"""Test bulk plan retrieval when all batches fail."""
|
||||
# Arrange
|
||||
tenant_ids = [f"tenant-{i}" for i in range(250)]
|
||||
|
||||
# All chunks fail
|
||||
def side_effect_func(*args, **kwargs):
|
||||
raise ValueError("API error")
|
||||
|
||||
mock_send_request.side_effect = side_effect_func
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert - should return empty dict
|
||||
assert result == {}
|
||||
assert mock_send_request.call_count == 2
|
||||
|
||||
def test_get_plan_bulk_with_exactly_200_tenants(self, mock_send_request):
|
||||
"""Test bulk plan retrieval with exactly 200 tenants (boundary condition)."""
|
||||
# Arrange
|
||||
tenant_ids = [f"tenant-{i}" for i in range(200)]
|
||||
mock_send_request.return_value = {
|
||||
"data": {f"tenant-{i}": {"plan": "sandbox", "expiration_date": 1735689600} for i in range(200)}
|
||||
}
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert len(result) == 200
|
||||
assert mock_send_request.call_count == 1
|
||||
|
||||
def test_get_plan_bulk_with_empty_data_response(self, mock_send_request):
|
||||
"""Test bulk plan retrieval with empty data in response."""
|
||||
# Arrange
|
||||
tenant_ids = ["tenant-1", "tenant-2"]
|
||||
mock_send_request.return_value = {"data": {}}
|
||||
|
||||
# Act
|
||||
result = BillingService.get_plan_bulk(tenant_ids)
|
||||
|
||||
# Assert
|
||||
assert result == {}
|
||||
|
||||
def test_get_expired_subscription_cleanup_whitelist_success(self, mock_send_request):
|
||||
"""Test successful retrieval of expired subscription cleanup whitelist."""
|
||||
# Arrange
|
||||
api_response = [
|
||||
{
|
||||
"created_at": "2025-10-16T01:56:17",
|
||||
"tenant_id": "36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe6",
|
||||
"contact": "[email protected]",
|
||||
"id": "36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe5",
|
||||
"expired_at": "2026-01-01T01:56:17",
|
||||
"updated_at": "2025-10-16T01:56:17",
|
||||
},
|
||||
{
|
||||
"created_at": "2025-10-16T02:00:00",
|
||||
"tenant_id": "tenant-2",
|
||||
"contact": "[email protected]",
|
||||
"id": "whitelist-id-2",
|
||||
"expired_at": "2026-02-01T00:00:00",
|
||||
"updated_at": "2025-10-16T02:00:00",
|
||||
},
|
||||
{
|
||||
"created_at": "2025-10-16T03:00:00",
|
||||
"tenant_id": "tenant-3",
|
||||
"contact": "[email protected]",
|
||||
"id": "whitelist-id-3",
|
||||
"expired_at": "2026-03-01T00:00:00",
|
||||
"updated_at": "2025-10-16T03:00:00",
|
||||
},
|
||||
]
|
||||
mock_send_request.return_value = {"data": api_response}
|
||||
|
||||
# Act
|
||||
result = BillingService.get_expired_subscription_cleanup_whitelist()
|
||||
|
||||
# Assert - should return only tenant_ids
|
||||
assert result == ["36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe6", "tenant-2", "tenant-3"]
|
||||
assert len(result) == 3
|
||||
assert result[0] == "36bd55ec-2ea9-4d75-a9ea-1f26aeb4ffe6"
|
||||
assert result[1] == "tenant-2"
|
||||
assert result[2] == "tenant-3"
|
||||
mock_send_request.assert_called_once_with("GET", "/subscription/cleanup/whitelist")
|
||||
|
||||
def test_get_expired_subscription_cleanup_whitelist_empty_list(self, mock_send_request):
|
||||
"""Test retrieval of empty cleanup whitelist."""
|
||||
# Arrange
|
||||
mock_send_request.return_value = {"data": []}
|
||||
|
||||
# Act
|
||||
result = BillingService.get_expired_subscription_cleanup_whitelist()
|
||||
|
||||
# Assert
|
||||
assert result == []
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestBillingServiceIntegrationScenarios:
|
||||
"""Integration-style tests simulating real-world usage scenarios.
|
||||
|
||||
|
||||
Generated
+4
-2
@@ -1380,7 +1380,7 @@ dependencies = [
|
||||
{ name = "bs4" },
|
||||
{ name = "cachetools" },
|
||||
{ name = "celery" },
|
||||
{ name = "chardet" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "croniter" },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-compress" },
|
||||
@@ -1403,6 +1403,7 @@ dependencies = [
|
||||
{ name = "httpx-sse" },
|
||||
{ name = "jieba" },
|
||||
{ name = "json-repair" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "langfuse" },
|
||||
{ name = "langsmith" },
|
||||
{ name = "litellm" },
|
||||
@@ -1577,7 +1578,7 @@ requires-dist = [
|
||||
{ name = "bs4", specifier = "~=0.0.1" },
|
||||
{ name = "cachetools", specifier = "~=5.3.0" },
|
||||
{ name = "celery", specifier = "~=5.5.2" },
|
||||
{ name = "chardet", specifier = "~=5.1.0" },
|
||||
{ name = "charset-normalizer", specifier = ">=3.4.4" },
|
||||
{ name = "croniter", specifier = ">=6.0.0" },
|
||||
{ name = "flask", specifier = "~=3.1.2" },
|
||||
{ name = "flask-compress", specifier = ">=1.17,<1.18" },
|
||||
@@ -1600,6 +1601,7 @@ requires-dist = [
|
||||
{ name = "httpx-sse", specifier = "~=0.4.0" },
|
||||
{ name = "jieba", specifier = "==0.42.1" },
|
||||
{ name = "json-repair", specifier = ">=0.41.1" },
|
||||
{ name = "jsonschema", specifier = ">=4.25.1" },
|
||||
{ name = "langfuse", specifier = "~=2.51.3" },
|
||||
{ name = "langsmith", specifier = "~=0.1.77" },
|
||||
{ name = "litellm", specifier = "==1.77.1" },
|
||||
|
||||
+3
-2
@@ -37,6 +37,7 @@ show_help() {
|
||||
echo " pipeline - Standard pipeline tasks"
|
||||
echo " triggered_workflow_dispatcher - Trigger dispatcher tasks"
|
||||
echo " trigger_refresh_executor - Trigger refresh tasks"
|
||||
echo " retention - Retention tasks"
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
@@ -105,10 +106,10 @@ if [[ -z "${QUEUES}" ]]; then
|
||||
# Configure queues based on edition
|
||||
if [[ "${EDITION}" == "CLOUD" ]]; then
|
||||
# Cloud edition: separate queues for dataset and trigger tasks
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
else
|
||||
# Community edition (SELF_HOSTED): dataset and workflow have separate queues
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor"
|
||||
QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
|
||||
fi
|
||||
|
||||
echo "No queues specified, using edition-based defaults: ${QUEUES}"
|
||||
|
||||
+9
-1
@@ -1369,7 +1369,10 @@ PLUGIN_STDIO_BUFFER_SIZE=1024
|
||||
PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880
|
||||
|
||||
PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120
|
||||
# Plugin Daemon side timeout (configure to match the API side below)
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT=600
|
||||
# API side timeout (configure to match the Plugin Daemon side above)
|
||||
PLUGIN_DAEMON_TIMEOUT=600.0
|
||||
# PIP_MIRROR_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
PIP_MIRROR_URL=
|
||||
|
||||
@@ -1479,4 +1482,9 @@ ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR=20
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT=5
|
||||
|
||||
# The API key of amplitude
|
||||
AMPLITUDE_API_KEY=
|
||||
AMPLITUDE_API_KEY=
|
||||
|
||||
# Sandbox expired records clean configuration
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
|
||||
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
PLUGIN_REMOTE_INSTALL_HOST: ${EXPOSE_PLUGIN_DEBUGGING_HOST:-localhost}
|
||||
PLUGIN_REMOTE_INSTALL_PORT: ${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
depends_on:
|
||||
init_permissions:
|
||||
|
||||
@@ -591,6 +591,7 @@ x-shared-env: &shared-api-worker-env
|
||||
PLUGIN_STDIO_MAX_BUFFER_SIZE: ${PLUGIN_STDIO_MAX_BUFFER_SIZE:-5242880}
|
||||
PLUGIN_PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
|
||||
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
PLUGIN_STORAGE_TYPE: ${PLUGIN_STORAGE_TYPE:-local}
|
||||
PLUGIN_STORAGE_LOCAL_ROOT: ${PLUGIN_STORAGE_LOCAL_ROOT:-/app/storage}
|
||||
@@ -663,6 +664,9 @@ x-shared-env: &shared-api-worker-env
|
||||
ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR: ${ANNOTATION_IMPORT_RATE_LIMIT_PER_HOUR:-20}
|
||||
ANNOTATION_IMPORT_MAX_CONCURRENT: ${ANNOTATION_IMPORT_MAX_CONCURRENT:-5}
|
||||
AMPLITUDE_API_KEY: ${AMPLITUDE_API_KEY:-}
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD: ${SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD:-21}
|
||||
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000}
|
||||
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
|
||||
|
||||
services:
|
||||
# Init container to fix permissions
|
||||
@@ -699,6 +703,7 @@ services:
|
||||
PLUGIN_REMOTE_INSTALL_HOST: ${EXPOSE_PLUGIN_DEBUGGING_HOST:-localhost}
|
||||
PLUGIN_REMOTE_INSTALL_PORT: ${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
depends_on:
|
||||
init_permissions:
|
||||
|
||||
@@ -61,14 +61,14 @@
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/2152" target="_blank"><img src="https://trendshift.io/api/badge/repositories/2152" alt="langgenius%2Fdify | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
Dify est une plateforme de développement d'applications LLM open source. Son interface intuitive combine un flux de travail d'IA, un pipeline RAG, des capacités d'agent, une gestion de modèles, des fonctionnalités d'observabilité, et plus encore, vous permettant de passer rapidement du prototype à la production. Voici une liste des fonctionnalités principales:
|
||||
Dify est une plateforme de développement d'applications LLM open source. Sa interface intuitive combine un flux de travail d'IA, un pipeline RAG, des capacités d'agent, une gestion de modèles, des fonctionnalités d'observabilité, et plus encore, vous permettant de passer rapidement du prototype à la production. Voici une liste des fonctionnalités principales:
|
||||
</br> </br>
|
||||
|
||||
**1. Flux de travail** :
|
||||
Construisez et testez des flux de travail d'IA puissants sur un canevas visuel, en utilisant toutes les fonctionnalités suivantes et plus encore.
|
||||
|
||||
**2. Prise en charge complète des modèles** :
|
||||
Intégration transparente avec des centaines de LLM propriétaires / open source provenant de dizaines de fournisseurs d'inférence et de solutions auto-hébergées, couvrant GPT, Mistral, Llama3, et tous les modèles compatibles avec l'API OpenAI. Une liste complète des fournisseurs de modèles pris en charge se trouve [ici](https://docs.dify.ai/getting-started/readme/model-providers).
|
||||
Intégration transparente avec des centaines de LLM propriétaires / open source offerts par dizaines de fournisseurs d'inférence et de solutions auto-hébergées, couvrant GPT, Mistral, Llama3, et tous les modèles compatibles avec l'API OpenAI. Une liste complète des fournisseurs de modèles pris en charge se trouve [ici](https://docs.dify.ai/getting-started/readme/model-providers).
|
||||
|
||||

|
||||
|
||||
@@ -79,7 +79,7 @@ Interface intuitive pour créer des prompts, comparer les performances des modè
|
||||
Des capacités RAG étendues qui couvrent tout, de l'ingestion de documents à la récupération, avec un support prêt à l'emploi pour l'extraction de texte à partir de PDF, PPT et autres formats de document courants.
|
||||
|
||||
**5. Capacités d'agent** :
|
||||
Vous pouvez définir des agents basés sur l'appel de fonction LLM ou ReAct, et ajouter des outils pré-construits ou personnalisés pour l'agent. Dify fournit plus de 50 outils intégrés pour les agents d'IA, tels que la recherche Google, DALL·E, Stable Diffusion et WolframAlpha.
|
||||
Vous pouvez définir des agents basés sur l'appel de fonctions LLM ou ReAct, et ajouter des outils pré-construits ou personnalisés pour l'agent. Dify fournit plus de 50 outils intégrés pour les agents d'IA, tels que la recherche Google, DALL·E, Stable Diffusion et WolframAlpha.
|
||||
|
||||
**6. LLMOps** :
|
||||
Surveillez et analysez les journaux d'application et les performances au fil du temps. Vous pouvez continuellement améliorer les prompts, les ensembles de données et les modèles en fonction des données de production et des annotations.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Mock for ky HTTP client
|
||||
* This mock is used to avoid ESM issues in Jest tests
|
||||
*/
|
||||
|
||||
type KyResponse = {
|
||||
ok: boolean
|
||||
status: number
|
||||
statusText: string
|
||||
headers: Headers
|
||||
json: jest.Mock
|
||||
text: jest.Mock
|
||||
blob: jest.Mock
|
||||
arrayBuffer: jest.Mock
|
||||
clone: jest.Mock
|
||||
}
|
||||
|
||||
type KyInstance = jest.Mock & {
|
||||
get: jest.Mock
|
||||
post: jest.Mock
|
||||
put: jest.Mock
|
||||
patch: jest.Mock
|
||||
delete: jest.Mock
|
||||
head: jest.Mock
|
||||
create: jest.Mock
|
||||
extend: jest.Mock
|
||||
stop: symbol
|
||||
}
|
||||
|
||||
const createResponse = (data: unknown = {}, status = 200): KyResponse => {
|
||||
const response: KyResponse = {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
headers: new Headers(),
|
||||
json: jest.fn().mockResolvedValue(data),
|
||||
text: jest.fn().mockResolvedValue(JSON.stringify(data)),
|
||||
blob: jest.fn().mockResolvedValue(new Blob()),
|
||||
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)),
|
||||
clone: jest.fn(),
|
||||
}
|
||||
// Ensure clone returns a new response-like object, not the same instance
|
||||
response.clone.mockImplementation(() => createResponse(data, status))
|
||||
return response
|
||||
}
|
||||
|
||||
const createKyInstance = (): KyInstance => {
|
||||
const instance = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) as KyInstance
|
||||
|
||||
// HTTP methods
|
||||
instance.get = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
|
||||
instance.post = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
|
||||
instance.put = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
|
||||
instance.patch = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
|
||||
instance.delete = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
|
||||
instance.head = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
|
||||
|
||||
// Create new instance with custom options
|
||||
instance.create = jest.fn().mockImplementation(() => createKyInstance())
|
||||
instance.extend = jest.fn().mockImplementation(() => createKyInstance())
|
||||
|
||||
// Stop method for AbortController
|
||||
instance.stop = Symbol('stop')
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
const ky = createKyInstance()
|
||||
|
||||
export default ky
|
||||
export { ky }
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import s from './style.module.css'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import AppSideBar from '@/app/components/app-sidebar'
|
||||
import type { NavIcon } from '@/app/components/app-sidebar/navLink'
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { RiCalendarLine } from '@remixicon/react'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { formatToLocalTime } from '@/utils/format'
|
||||
import { useI18N } from '@/context/i18n'
|
||||
import Picker from '@/app/components/base/date-and-time-picker/date-picker'
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { SimpleSelect } from '@/app/components/base/select'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import dayjs from 'dayjs'
|
||||
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const today = dayjs()
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import React, { useCallback, useRef, useState } from 'react'
|
||||
|
||||
import type { PopupProps } from './config-popup'
|
||||
import ConfigPopup from './config-popup'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import Indicator from '@/app/components/header/indicator'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const I18N_PREFIX = 'app.tracing'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangS
|
||||
import { TracingProvider } from './type'
|
||||
import TracingIcon from './tracing-icon'
|
||||
import ConfigButton from './config-button'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps'
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TracingProvider } from './type'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { AliyunIconBig, ArizeIconBig, DatabricksIconBig, LangfuseIconBig, LangsmithIconBig, MlflowIconBig, OpikIconBig, PhoenixIconBig, TencentIconBig, WeaveIconBig } from '@/app/components/base/icons/src/public/tracing'
|
||||
import { Eye as View } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { TracingIcon as Icon } from '@/app/components/base/icons/src/public/tracing'
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import ExtraInfo from '@/app/components/datasets/extra-info'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type IAppDetailLayoutProps = {
|
||||
children: React.ReactNode
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import Header from '@/app/signin/_header'
|
||||
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import cn from 'classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { RiCheckboxCircleFill } from '@remixicon/react'
|
||||
import { useCountDown } from 'ahooks'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
||||
@@ -7,7 +7,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import MailAndCodeAuth from './components/mail-and-code-auth'
|
||||
import MailAndPasswordAuth from './components/mail-and-password-auth'
|
||||
import SSOAuth from './components/sso-auth'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { LicenseStatus } from '@/types/feature'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import Header from '@/app/signin/_header'
|
||||
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { AppContextProvider } from '@/context/app-context'
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
import { invitationCheck } from '@/service/common'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useInvitationCheck } from '@/service/use-common'
|
||||
|
||||
const ActivateForm = () => {
|
||||
useDocumentTitle('')
|
||||
@@ -26,19 +26,21 @@ const ActivateForm = () => {
|
||||
token,
|
||||
},
|
||||
}
|
||||
const { data: checkRes } = useSWR(checkParams, invitationCheck, {
|
||||
revalidateOnFocus: false,
|
||||
onSuccess(data) {
|
||||
if (data.is_valid) {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
const { email, workspace_id } = data.data
|
||||
params.set('email', encodeURIComponent(email))
|
||||
params.set('workspace_id', encodeURIComponent(workspace_id))
|
||||
params.set('invite_token', encodeURIComponent(token as string))
|
||||
router.replace(`/signin?${params.toString()}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
const { data: checkRes } = useInvitationCheck({
|
||||
...checkParams.params,
|
||||
token: token || undefined,
|
||||
}, true)
|
||||
|
||||
useEffect(() => {
|
||||
if (checkRes?.is_valid) {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
const { email, workspace_id } = checkRes.data
|
||||
params.set('email', encodeURIComponent(email))
|
||||
params.set('workspace_id', encodeURIComponent(workspace_id))
|
||||
params.set('invite_token', encodeURIComponent(token as string))
|
||||
router.replace(`/signin?${params.toString()}`)
|
||||
}
|
||||
}, [checkRes, router, searchParams, token])
|
||||
|
||||
return (
|
||||
<div className={
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from 'react'
|
||||
import Header from '../signin/_header'
|
||||
import ActivateForm from './activateForm'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
const Activate = () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overvie
|
||||
import type { Operation } from './app-operations'
|
||||
import AppOperations from './app-operations'
|
||||
import dynamic from 'next/dynamic'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
|
||||
|
||||
@@ -16,7 +16,7 @@ import AppInfo from './app-info'
|
||||
import NavLink from './navLink'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { NavIcon } from './navLink'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useState } from 'react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||
import ActionButton from '../../base/action-button'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Menu from './menu'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import DatasetInfo from './index'
|
||||
import Dropdown from './dropdown'
|
||||
import Menu from './menu'
|
||||
import MenuItem from './menu-item'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import {
|
||||
ChunkingMode,
|
||||
DataSourceType,
|
||||
DatasetPermission,
|
||||
} from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
|
||||
let mockDataset: DataSet
|
||||
let mockIsDatasetOperator = false
|
||||
const mockReplace = jest.fn()
|
||||
const mockInvalidDatasetList = jest.fn()
|
||||
const mockInvalidDatasetDetail = jest.fn()
|
||||
const mockExportPipeline = jest.fn()
|
||||
const mockCheckIsUsedInApp = jest.fn()
|
||||
const mockDeleteDataset = jest.fn()
|
||||
|
||||
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset Name',
|
||||
indexing_status: 'completed',
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_type: 'emoji',
|
||||
icon_url: '',
|
||||
},
|
||||
description: 'Dataset description',
|
||||
permission: DatasetPermission.onlyMe,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
|
||||
created_by: 'user-1',
|
||||
updated_by: 'user-1',
|
||||
updated_at: 1690000000,
|
||||
app_count: 0,
|
||||
doc_form: ChunkingMode.text,
|
||||
document_count: 1,
|
||||
total_document_count: 1,
|
||||
word_count: 1000,
|
||||
provider: 'internal',
|
||||
embedding_model: 'text-embedding-3',
|
||||
embedding_model_provider: 'openai',
|
||||
embedding_available: true,
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
retrieval_model: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 5,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0,
|
||||
},
|
||||
tags: [],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 0,
|
||||
score_threshold: 0,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
built_in_field_enabled: false,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
enable_api: false,
|
||||
is_multimodal: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) =>
|
||||
selector({ isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator }),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/knowledge/use-dataset', () => ({
|
||||
datasetDetailQueryKeyPrefix: ['dataset', 'detail'],
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
jest.mock('@/service/use-base', () => ({
|
||||
useInvalid: () => mockInvalidDatasetDetail,
|
||||
}))
|
||||
|
||||
jest.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({
|
||||
mutateAsync: mockExportPipeline,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/datasets', () => ({
|
||||
checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args),
|
||||
deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'indexing-technique',
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/datasets/rename-modal', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
show,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
onSuccess?: () => void
|
||||
}) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="rename-modal">
|
||||
<button type="button" onClick={onSuccess}>Success</button>
|
||||
<button type="button" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
const trigger = screen.getByRole('button')
|
||||
await user.click(trigger)
|
||||
}
|
||||
|
||||
describe('DatasetInfo', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockDataset = createDataset()
|
||||
mockIsDatasetOperator = false
|
||||
})
|
||||
|
||||
// Rendering of dataset summary details based on expand and dataset state.
|
||||
describe('Rendering', () => {
|
||||
it('should show dataset details when expanded', () => {
|
||||
// Arrange
|
||||
mockDataset = createDataset({ is_published: true })
|
||||
render(<DatasetInfo expand />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dataset description')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.chunkingMode.general')).toBeInTheDocument()
|
||||
expect(screen.getByText('indexing-technique')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show external tag when provider is external', () => {
|
||||
// Arrange
|
||||
mockDataset = createDataset({ provider: 'external', is_published: false })
|
||||
render(<DatasetInfo expand />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
|
||||
expect(screen.queryByText('dataset.chunkingMode.general')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide detailed fields when collapsed', () => {
|
||||
// Arrange
|
||||
render(<DatasetInfo expand={false} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('Dataset Name')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Dataset description')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MenuItem', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Event handling for menu item interactions.
|
||||
describe('Interactions', () => {
|
||||
it('should call handler when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleClick = jest.fn()
|
||||
// Arrange
|
||||
render(<MenuItem name="Edit" Icon={RiEditLine} handleClick={handleClick} />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByText('Edit'))
|
||||
|
||||
// Assert
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Menu', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockDataset = createDataset()
|
||||
})
|
||||
|
||||
// Rendering of menu options based on runtime mode and delete visibility.
|
||||
describe('Rendering', () => {
|
||||
it('should show edit, export, and delete options when rag pipeline and deletable', () => {
|
||||
// Arrange
|
||||
mockDataset = createDataset({ runtime_mode: 'rag_pipeline' })
|
||||
render(
|
||||
<Menu
|
||||
showDelete
|
||||
openRenameModal={jest.fn()}
|
||||
handleExportPipeline={jest.fn()}
|
||||
detectIsUsedByApp={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
|
||||
expect(screen.getByText('datasetPipeline.operations.exportPipeline')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide export and delete options when not rag pipeline and not deletable', () => {
|
||||
// Arrange
|
||||
mockDataset = createDataset({ runtime_mode: 'general' })
|
||||
render(
|
||||
<Menu
|
||||
showDelete={false}
|
||||
openRenameModal={jest.fn()}
|
||||
handleExportPipeline={jest.fn()}
|
||||
detectIsUsedByApp={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
|
||||
expect(screen.queryByText('datasetPipeline.operations.exportPipeline')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropdown', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' })
|
||||
mockIsDatasetOperator = false
|
||||
mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' })
|
||||
mockCheckIsUsedInApp.mockResolvedValue({ is_using: false })
|
||||
mockDeleteDataset.mockResolvedValue({})
|
||||
if (!('createObjectURL' in URL)) {
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
if (!('revokeObjectURL' in URL)) {
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Rendering behavior based on workspace role.
|
||||
describe('Rendering', () => {
|
||||
it('should hide delete option when user is dataset operator', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
mockIsDatasetOperator = true
|
||||
render(<Dropdown expand />)
|
||||
|
||||
// Act
|
||||
await openMenu(user)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions that trigger modals and exports.
|
||||
describe('Interactions', () => {
|
||||
it('should open rename modal when edit is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<Dropdown expand />)
|
||||
|
||||
// Act
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should export pipeline when export is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const anchorClickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL')
|
||||
// Arrange
|
||||
render(<Dropdown expand />)
|
||||
|
||||
// Act
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockExportPipeline).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-1',
|
||||
include: false,
|
||||
})
|
||||
})
|
||||
expect(createObjectURLSpy).toHaveBeenCalledTimes(1)
|
||||
expect(anchorClickSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show delete confirmation when delete is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<Dropdown expand />)
|
||||
|
||||
// Act
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dataset.deleteDatasetConfirmContent')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete dataset and redirect when confirm is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
// Arrange
|
||||
render(<Dropdown expand />)
|
||||
|
||||
// Act
|
||||
await openMenu(user)
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
|
||||
})
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1)
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { DOC_FORM_TEXT } from '@/models/datasets'
|
||||
import { useKnowledge } from '@/hooks/use-knowledge'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Dropdown from './dropdown'
|
||||
|
||||
type DatasetInfoProps = {
|
||||
|
||||
@@ -11,7 +11,7 @@ import AppIcon from '../base/app-icon'
|
||||
import Divider from '../base/divider'
|
||||
import NavLink from './navLink'
|
||||
import type { NavIcon } from './navLink'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import Effect from '../base/effect'
|
||||
import Dropdown from './dataset-info/dropdown'
|
||||
|
||||
@@ -9,7 +9,7 @@ import AppSidebarDropdown from './app-sidebar-dropdown'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Divider from '../base/divider'
|
||||
import { useHover, useKeyPress } from 'ahooks'
|
||||
import ToggleButton from './toggle-button'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React from 'react'
|
||||
import { useSelectedLayoutSegment } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
|
||||
export type NavIcon = React.ComponentType<
|
||||
@@ -42,7 +42,7 @@ const NavLink = ({
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={classNames(mode !== 'expand' && '-ml-1')}>
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
<NavIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
@@ -53,21 +53,17 @@ const NavLink = ({
|
||||
key={name}
|
||||
type='button'
|
||||
disabled
|
||||
className={classNames(
|
||||
'system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
|
||||
'pl-3 pr-1',
|
||||
)}
|
||||
className={cn('system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
|
||||
'pl-3 pr-1')}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
aria-disabled
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={classNames(
|
||||
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
|
||||
mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0',
|
||||
)}
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
@@ -79,22 +75,18 @@ const NavLink = ({
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={classNames(
|
||||
isActive
|
||||
? 'system-sm-semibold border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
|
||||
'flex h-8 items-center rounded-lg pl-3 pr-1',
|
||||
)}
|
||||
className={cn(isActive
|
||||
? 'system-sm-semibold border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
|
||||
'flex h-8 items-center rounded-lg pl-3 pr-1')}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={classNames(
|
||||
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
|
||||
mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0',
|
||||
)}
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import Button from '../base/button'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Tooltip from '../base/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getKeyboardKeyNameBySystem } from '../workflow/utils'
|
||||
|
||||
@@ -2,12 +2,6 @@ import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import EditItem, { EditItemType } from './index'
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('AddAnnotationModal/EditItem', () => {
|
||||
test('should render query inputs with user avatar and placeholder strings', () => {
|
||||
render(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import BatchAction from './batch-action'
|
||||
|
||||
describe('BatchAction', () => {
|
||||
const baseProps = {
|
||||
selectedIds: ['1', '2', '3'],
|
||||
onBatchDelete: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should show the selected count and trigger cancel action', () => {
|
||||
render(<BatchAction {...baseProps} className='custom-class' />)
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.batchAction.selected')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(baseProps.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should confirm before running batch delete', async () => {
|
||||
const onBatchDelete = jest.fn().mockResolvedValue(undefined)
|
||||
render(<BatchAction {...baseProps} onBatchDelete={onBatchDelete} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.delete' }))
|
||||
await screen.findByText('appAnnotation.list.delete.title')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[1])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onBatchDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { RiDeleteBinLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
|
||||
const i18nPrefix = 'appAnnotation.batchAction'
|
||||
@@ -38,7 +38,7 @@ const BatchAction: FC<IBatchActionProps> = ({
|
||||
setIsNotDeleting()
|
||||
}
|
||||
return (
|
||||
<div className={classNames('pointer-events-none flex w-full justify-center', className)}>
|
||||
<div className={cn('pointer-events-none flex w-full justify-center', className)}>
|
||||
<div className='pointer-events-auto flex items-center gap-x-1 rounded-[10px] border border-components-actionbar-border-accent bg-components-actionbar-bg-accent p-1 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]'>
|
||||
<div className='inline-flex items-center gap-x-2 py-1 pl-2 pr-3'>
|
||||
<span className='flex h-5 w-5 items-center justify-center rounded-md bg-text-accent px-1 py-0.5 text-xs font-medium text-text-primary-on-surface'>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import CSVDownload from './csv-downloader'
|
||||
import I18nContext from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
|
||||
const downloaderProps: any[] = []
|
||||
|
||||
jest.mock('react-papaparse', () => ({
|
||||
useCSVDownloader: jest.fn(() => ({
|
||||
CSVDownloader: ({ children, ...props }: any) => {
|
||||
downloaderProps.push(props)
|
||||
return <div data-testid="mock-csv-downloader">{children}</div>
|
||||
},
|
||||
Type: { Link: 'link' },
|
||||
})),
|
||||
}))
|
||||
|
||||
const renderWithLocale = (locale: Locale) => {
|
||||
return render(
|
||||
<I18nContext.Provider value={{
|
||||
locale,
|
||||
i18n: {},
|
||||
setLocaleOnClient: jest.fn().mockResolvedValue(undefined),
|
||||
}}
|
||||
>
|
||||
<CSVDownload />
|
||||
</I18nContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('CSVDownload', () => {
|
||||
const englishTemplate = [
|
||||
['question', 'answer'],
|
||||
['question1', 'answer1'],
|
||||
['question2', 'answer2'],
|
||||
]
|
||||
const chineseTemplate = [
|
||||
['问题', '答案'],
|
||||
['问题 1', '答案 1'],
|
||||
['问题 2', '答案 2'],
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
downloaderProps.length = 0
|
||||
})
|
||||
|
||||
it('should render the structure preview and pass English template data by default', () => {
|
||||
renderWithLocale('en-US' as Locale)
|
||||
|
||||
expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.batchModal.template')).toBeInTheDocument()
|
||||
|
||||
expect(downloaderProps[0]).toMatchObject({
|
||||
filename: 'template-en-US',
|
||||
type: 'link',
|
||||
bom: true,
|
||||
data: englishTemplate,
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch to the Chinese template when locale matches the secondary language', () => {
|
||||
const locale = LanguagesSupported[1] as Locale
|
||||
renderWithLocale(locale)
|
||||
|
||||
expect(downloaderProps[0]).toMatchObject({
|
||||
filename: `template-${locale}`,
|
||||
data: chineseTemplate,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiDeleteBinLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import BatchModal, { ProcessStatus } from './index'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
|
||||
import type { IBatchModalProps } from './index'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
notify: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/service/annotation', () => ({
|
||||
annotationBatchImport: jest.fn(),
|
||||
checkAnnotationBatchImportProgress: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('./csv-downloader', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="csv-downloader-stub" />,
|
||||
}))
|
||||
|
||||
let lastUploadedFile: File | undefined
|
||||
|
||||
jest.mock('./csv-uploader', () => ({
|
||||
__esModule: true,
|
||||
default: ({ file, updateFile }: { file?: File; updateFile: (file?: File) => void }) => (
|
||||
<div>
|
||||
<button
|
||||
data-testid="mock-uploader"
|
||||
onClick={() => {
|
||||
lastUploadedFile = new File(['question,answer'], 'batch.csv', { type: 'text/csv' })
|
||||
updateFile(lastUploadedFile)
|
||||
}}
|
||||
>
|
||||
upload
|
||||
</button>
|
||||
{file && <span data-testid="selected-file">{file.name}</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/billing/annotation-full', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="annotation-full" />,
|
||||
}))
|
||||
|
||||
const mockNotify = Toast.notify as jest.Mock
|
||||
const useProviderContextMock = useProviderContext as jest.Mock
|
||||
const annotationBatchImportMock = annotationBatchImport as jest.Mock
|
||||
const checkAnnotationBatchImportProgressMock = checkAnnotationBatchImportProgress as jest.Mock
|
||||
|
||||
const renderComponent = (props: Partial<IBatchModalProps> = {}) => {
|
||||
const mergedProps: IBatchModalProps = {
|
||||
appId: 'app-id',
|
||||
isShow: true,
|
||||
onCancel: jest.fn(),
|
||||
onAdded: jest.fn(),
|
||||
...props,
|
||||
}
|
||||
return {
|
||||
...render(<BatchModal {...mergedProps} />),
|
||||
props: mergedProps,
|
||||
}
|
||||
}
|
||||
|
||||
describe('BatchModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
lastUploadedFile = undefined
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable run action and show billing hint when annotation quota is full', () => {
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: true,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'appAnnotation.batchModal.run' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should reset uploader state when modal closes and allow manual cancellation', () => {
|
||||
const { rerender, props } = renderComponent()
|
||||
|
||||
fireEvent.click(screen.getByTestId('mock-uploader'))
|
||||
expect(screen.getByTestId('selected-file')).toHaveTextContent('batch.csv')
|
||||
|
||||
rerender(<BatchModal {...props} isShow={false} />)
|
||||
rerender(<BatchModal {...props} isShow />)
|
||||
|
||||
expect(screen.queryByTestId('selected-file')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }))
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should submit the csv file, poll status, and notify when import completes', async () => {
|
||||
jest.useFakeTimers()
|
||||
const { props } = renderComponent()
|
||||
const fileTrigger = screen.getByTestId('mock-uploader')
|
||||
fireEvent.click(fileTrigger)
|
||||
|
||||
const runButton = screen.getByRole('button', { name: 'appAnnotation.batchModal.run' })
|
||||
expect(runButton).not.toBeDisabled()
|
||||
|
||||
annotationBatchImportMock.mockResolvedValue({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING })
|
||||
checkAnnotationBatchImportProgressMock
|
||||
.mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.PROCESSING })
|
||||
.mockResolvedValueOnce({ job_id: 'job-1', job_status: ProcessStatus.COMPLETED })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(runButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(annotationBatchImportMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const formData = annotationBatchImportMock.mock.calls[0][0].body as FormData
|
||||
expect(formData.get('file')).toBe(lastUploadedFile)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkAnnotationBatchImportProgressMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
jest.runOnlyPendingTimers()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkAnnotationBatchImportProgressMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'appAnnotation.batchModal.completed',
|
||||
})
|
||||
expect(props.onAdded).toHaveBeenCalledTimes(1)
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
jest.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -245,7 +245,7 @@ describe('EditItem', () => {
|
||||
expect(mockSave).toHaveBeenCalledWith('Test save content')
|
||||
})
|
||||
|
||||
it('should show delete option when content changes', async () => {
|
||||
it('should show delete option and restore original content when delete is clicked', async () => {
|
||||
// Arrange
|
||||
const mockSave = jest.fn().mockResolvedValue(undefined)
|
||||
const props = {
|
||||
@@ -267,7 +267,13 @@ describe('EditItem', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert
|
||||
expect(mockSave).toHaveBeenCalledWith('Modified content')
|
||||
expect(mockSave).toHaveBeenNthCalledWith(1, 'Modified content')
|
||||
expect(await screen.findByText('common.operation.delete')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
expect(mockSave).toHaveBeenNthCalledWith(2, 'Test content')
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle keyboard interactions in edit mode', async () => {
|
||||
@@ -393,5 +399,68 @@ describe('EditItem', () => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle save failure gracefully in edit mode', async () => {
|
||||
// Arrange
|
||||
const mockSave = jest.fn().mockRejectedValueOnce(new Error('Save failed'))
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onSave: mockSave,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Enter edit mode and save (should fail)
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.type(textarea, 'New content')
|
||||
|
||||
// Save should fail but not throw
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
// Assert - Should remain in edit mode when save fails
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
expect(mockSave).toHaveBeenCalledWith('New content')
|
||||
})
|
||||
|
||||
it('should handle delete action failure gracefully', async () => {
|
||||
// Arrange
|
||||
const mockSave = jest.fn()
|
||||
.mockResolvedValueOnce(undefined) // First save succeeds
|
||||
.mockRejectedValueOnce(new Error('Delete failed')) // Delete fails
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onSave: mockSave,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditItem {...props} />)
|
||||
|
||||
// Edit content to show delete button
|
||||
await user.click(screen.getByText('common.operation.edit'))
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Modified content')
|
||||
|
||||
// Save to create new content
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
await screen.findByText('common.operation.delete')
|
||||
|
||||
// Click delete (should fail but not throw)
|
||||
await user.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
// Assert - Delete action should handle error gracefully
|
||||
expect(mockSave).toHaveBeenCalledTimes(2)
|
||||
expect(mockSave).toHaveBeenNthCalledWith(1, 'Modified content')
|
||||
expect(mockSave).toHaveBeenNthCalledWith(2, 'Test content')
|
||||
|
||||
// When delete fails, the delete button should still be visible (state not changed)
|
||||
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('Modified content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react'
|
||||
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export enum EditItemType {
|
||||
Query = 'query',
|
||||
@@ -52,8 +52,14 @@ const EditItem: FC<Props> = ({
|
||||
}, [content])
|
||||
|
||||
const handleSave = async () => {
|
||||
await onSave(newContent)
|
||||
setIsEdit(false)
|
||||
try {
|
||||
await onSave(newContent)
|
||||
setIsEdit(false)
|
||||
}
|
||||
catch {
|
||||
// Keep edit mode open when save fails
|
||||
// Error notification is handled by the parent component
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -96,9 +102,16 @@ const EditItem: FC<Props> = ({
|
||||
<div className='mr-2'>·</div>
|
||||
<div
|
||||
className='flex cursor-pointer items-center space-x-1'
|
||||
onClick={() => {
|
||||
setNewContent(content)
|
||||
onSave(content)
|
||||
onClick={async () => {
|
||||
try {
|
||||
await onSave(content)
|
||||
// Only update UI state after successful delete
|
||||
setNewContent(content)
|
||||
}
|
||||
catch {
|
||||
// Delete action failed - error is already handled by parent
|
||||
// UI state remains unchanged, user can retry
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='h-3.5 w-3.5'>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Toast, { type IToastProps, type ToastHandle } from '@/app/components/base/toast'
|
||||
import EditAnnotationModal from './index'
|
||||
@@ -405,4 +405,276 @@ describe('EditAnnotationModal', () => {
|
||||
expect(editLinks).toHaveLength(1) // Only answer should have edit button
|
||||
})
|
||||
})
|
||||
|
||||
// Error Handling (CRITICAL for coverage)
|
||||
describe('Error Handling', () => {
|
||||
it('should show error toast and skip callbacks when addAnnotation fails', async () => {
|
||||
// Arrange
|
||||
const mockOnAdded = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAdded: mockOnAdded,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mock API failure
|
||||
mockAddAnnotation.mockRejectedValueOnce(new Error('API Error'))
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Find and click edit link for query
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0])
|
||||
|
||||
// Find textarea and enter new content
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New query content')
|
||||
|
||||
// Click save button
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith({
|
||||
message: 'API Error',
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
expect(mockOnAdded).not.toHaveBeenCalled()
|
||||
|
||||
// Verify edit mode remains open (textarea should still be visible)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show fallback error message when addAnnotation error has no message', async () => {
|
||||
// Arrange
|
||||
const mockOnAdded = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onAdded: mockOnAdded,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
mockAddAnnotation.mockRejectedValueOnce({})
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0])
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New query content')
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith({
|
||||
message: 'common.api.actionFailed',
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
expect(mockOnAdded).not.toHaveBeenCalled()
|
||||
|
||||
// Verify edit mode remains open (textarea should still be visible)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error toast and skip callbacks when editAnnotation fails', async () => {
|
||||
// Arrange
|
||||
const mockOnEdited = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
messageId: 'test-message-id',
|
||||
onEdited: mockOnEdited,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mock API failure
|
||||
mockEditAnnotation.mockRejectedValueOnce(new Error('API Error'))
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Edit query content
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0])
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Modified query')
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith({
|
||||
message: 'API Error',
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
expect(mockOnEdited).not.toHaveBeenCalled()
|
||||
|
||||
// Verify edit mode remains open (textarea should still be visible)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show fallback error message when editAnnotation error is not an Error instance', async () => {
|
||||
// Arrange
|
||||
const mockOnEdited = jest.fn()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
messageId: 'test-message-id',
|
||||
onEdited: mockOnEdited,
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
mockEditAnnotation.mockRejectedValueOnce('oops')
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0])
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Modified query')
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith({
|
||||
message: 'common.api.actionFailed',
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
expect(mockOnEdited).not.toHaveBeenCalled()
|
||||
|
||||
// Verify edit mode remains open (textarea should still be visible)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Billing & Plan Features
|
||||
describe('Billing & Plan Features', () => {
|
||||
it('should show createdAt time when provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
createdAt: 1701381000, // 2023-12-01 10:30:00
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Check that the formatted time appears somewhere in the component
|
||||
const container = screen.getByRole('dialog')
|
||||
expect(container).toHaveTextContent('2023-12-01 10:30:00')
|
||||
})
|
||||
|
||||
it('should not show createdAt when not provided', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
// createdAt is undefined
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Should not contain any timestamp
|
||||
const container = screen.getByRole('dialog')
|
||||
expect(container).not.toHaveTextContent('2023-12-01 10:30:00')
|
||||
})
|
||||
|
||||
it('should display remove section when annotationId exists', () => {
|
||||
// Arrange
|
||||
const props = {
|
||||
...defaultProps,
|
||||
annotationId: 'test-annotation-id',
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Should have remove functionality
|
||||
expect(screen.getByText('appAnnotation.editModal.removeThisCache')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Toast Notifications (Success)
|
||||
describe('Toast Notifications', () => {
|
||||
it('should show success notification when save operation completes', async () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Act
|
||||
render(<EditAnnotationModal {...props} />)
|
||||
|
||||
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
|
||||
await user.click(editLinks[0])
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'Updated query')
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(saveButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith({
|
||||
message: 'common.api.actionSuccess',
|
||||
type: 'success',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// React.memo Performance Testing
|
||||
describe('React.memo Performance', () => {
|
||||
it('should not re-render when props are the same', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const { rerender } = render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Act - Re-render with same props
|
||||
rerender(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Assert - Component should still be visible (no errors thrown)
|
||||
expect(screen.getByText('appAnnotation.editModal.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-render when props change', () => {
|
||||
// Arrange
|
||||
const props = { ...defaultProps }
|
||||
const { rerender } = render(<EditAnnotationModal {...props} />)
|
||||
|
||||
// Act - Re-render with different props
|
||||
const newProps = { ...props, query: 'New query content' }
|
||||
rerender(<EditAnnotationModal {...newProps} />)
|
||||
|
||||
// Assert - Should show new content
|
||||
expect(screen.getByText('New query content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,27 +53,39 @@ const EditAnnotationModal: FC<Props> = ({
|
||||
postQuery = editedContent
|
||||
else
|
||||
postAnswer = editedContent
|
||||
if (!isAdd) {
|
||||
await editAnnotation(appId, annotationId, {
|
||||
message_id: messageId,
|
||||
question: postQuery,
|
||||
answer: postAnswer,
|
||||
})
|
||||
onEdited(postQuery, postAnswer)
|
||||
}
|
||||
else {
|
||||
const res: any = await addAnnotation(appId, {
|
||||
question: postQuery,
|
||||
answer: postAnswer,
|
||||
message_id: messageId,
|
||||
})
|
||||
onAdded(res.id, res.account?.name, postQuery, postAnswer)
|
||||
}
|
||||
try {
|
||||
if (!isAdd) {
|
||||
await editAnnotation(appId, annotationId, {
|
||||
message_id: messageId,
|
||||
question: postQuery,
|
||||
answer: postAnswer,
|
||||
})
|
||||
onEdited(postQuery, postAnswer)
|
||||
}
|
||||
else {
|
||||
const res = await addAnnotation(appId, {
|
||||
question: postQuery,
|
||||
answer: postAnswer,
|
||||
message_id: messageId,
|
||||
})
|
||||
onAdded(res.id, res.account?.name ?? '', postQuery, postAnswer)
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
message: t('common.api.actionSuccess') as string,
|
||||
type: 'success',
|
||||
})
|
||||
Toast.notify({
|
||||
message: t('common.api.actionSuccess') as string,
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
const fallbackMessage = t('common.api.actionFailed') as string
|
||||
const message = error instanceof Error && error.message ? error.message : fallbackMessage
|
||||
Toast.notify({
|
||||
message,
|
||||
type: 'error',
|
||||
})
|
||||
// Re-throw to preserve edit mode behavior for UI components
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import EmptyElement from './empty-element'
|
||||
|
||||
describe('EmptyElement', () => {
|
||||
it('should render the empty state copy and supporting icon', () => {
|
||||
const { container } = render(<EmptyElement />)
|
||||
|
||||
expect(screen.getByText('appAnnotation.noData.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('appAnnotation.noData.description')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Filter, { type QueryParam } from './filter'
|
||||
import useSWR from 'swr'
|
||||
|
||||
jest.mock('swr', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/log', () => ({
|
||||
fetchAnnotationsCount: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockUseSWR = useSWR as unknown as jest.Mock
|
||||
|
||||
describe('Filter', () => {
|
||||
const appId = 'app-1'
|
||||
const childContent = 'child-content'
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render nothing until annotation count is fetched', () => {
|
||||
mockUseSWR.mockReturnValue({ data: undefined })
|
||||
|
||||
const { container } = render(
|
||||
<Filter
|
||||
appId={appId}
|
||||
queryParams={{ keyword: '' }}
|
||||
setQueryParams={jest.fn()}
|
||||
>
|
||||
<div>{childContent}</div>
|
||||
</Filter>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockUseSWR).toHaveBeenCalledWith(
|
||||
{ url: `/apps/${appId}/annotations/count` },
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('should propagate keyword changes and clearing behavior', () => {
|
||||
mockUseSWR.mockReturnValue({ data: { total: 20 } })
|
||||
const queryParams: QueryParam = { keyword: 'prefill' }
|
||||
const setQueryParams = jest.fn()
|
||||
|
||||
const { container } = render(
|
||||
<Filter
|
||||
appId={appId}
|
||||
queryParams={queryParams}
|
||||
setQueryParams={setQueryParams}
|
||||
>
|
||||
<div>{childContent}</div>
|
||||
</Filter>,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: 'updated' } })
|
||||
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: 'updated' })
|
||||
|
||||
const clearButton = input.parentElement?.querySelector('div.cursor-pointer') as HTMLElement
|
||||
fireEvent.click(clearButton)
|
||||
expect(setQueryParams).toHaveBeenCalledWith({ ...queryParams, keyword: '' })
|
||||
|
||||
expect(container).toHaveTextContent(childContent)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,439 @@
|
||||
import * as React from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ComponentProps } from 'react'
|
||||
import HeaderOptions from './index'
|
||||
import I18NContext from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import type { AnnotationItemBasic } from '../type'
|
||||
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
|
||||
|
||||
jest.mock('@headlessui/react', () => {
|
||||
type PopoverContextValue = { open: boolean; setOpen: (open: boolean) => void }
|
||||
type MenuContextValue = { open: boolean; setOpen: (open: boolean) => void }
|
||||
const PopoverContext = React.createContext<PopoverContextValue | null>(null)
|
||||
const MenuContext = React.createContext<MenuContextValue | null>(null)
|
||||
|
||||
const Popover = ({ children }: { children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) }) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const value = React.useMemo(() => ({ open, setOpen }), [open])
|
||||
return (
|
||||
<PopoverContext.Provider value={value}>
|
||||
{typeof children === 'function' ? children({ open }) : children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverButton = React.forwardRef(({ onClick, children, ...props }: { onClick?: () => void; children?: React.ReactNode }, ref: React.Ref<HTMLButtonElement>) => {
|
||||
const context = React.useContext(PopoverContext)
|
||||
const handleClick = () => {
|
||||
context?.setOpen(!context.open)
|
||||
onClick?.()
|
||||
}
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-expanded={context?.open ?? false}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
const PopoverPanel = React.forwardRef(({ children, ...props }: { children: React.ReactNode | ((props: { close: () => void }) => React.ReactNode) }, ref: React.Ref<HTMLDivElement>) => {
|
||||
const context = React.useContext(PopoverContext)
|
||||
if (!context?.open) return null
|
||||
const content = typeof children === 'function' ? children({ close: () => context.setOpen(false) }) : children
|
||||
return (
|
||||
<div ref={ref} {...props}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const Menu = ({ children }: { children: React.ReactNode }) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const value = React.useMemo(() => ({ open, setOpen }), [open])
|
||||
return (
|
||||
<MenuContext.Provider value={value}>
|
||||
{children}
|
||||
</MenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void; children?: React.ReactNode }) => {
|
||||
const context = React.useContext(MenuContext)
|
||||
const handleClick = () => {
|
||||
context?.setOpen(!context.open)
|
||||
onClick?.()
|
||||
}
|
||||
return (
|
||||
<button type="button" aria-expanded={context?.open ?? false} onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const MenuItems = ({ children, ...props }: { children: React.ReactNode }) => {
|
||||
const context = React.useContext(MenuContext)
|
||||
if (!context?.open) return null
|
||||
return (
|
||||
<div {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
Dialog: ({ open, children, className }: { open?: boolean; children: React.ReactNode; className?: string }) => {
|
||||
if (open === false) return null
|
||||
return (
|
||||
<div role="dialog" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
DialogBackdrop: ({ children, className, onClick }: { children?: React.ReactNode; className?: string; onClick?: () => void }) => (
|
||||
<div className={className} onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogPanel: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogTitle: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItems,
|
||||
Transition: ({ show = true, children }: { show?: boolean; children: React.ReactNode }) => (show ? <>{children}</> : null),
|
||||
TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}
|
||||
})
|
||||
|
||||
let lastCSVDownloaderProps: Record<string, unknown> | undefined
|
||||
const mockCSVDownloader = jest.fn(({ children, ...props }) => {
|
||||
lastCSVDownloaderProps = props
|
||||
return (
|
||||
<div data-testid="csv-downloader">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
jest.mock('react-papaparse', () => ({
|
||||
useCSVDownloader: () => ({
|
||||
CSVDownloader: (props: any) => mockCSVDownloader(props),
|
||||
Type: { Link: 'link' },
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/annotation', () => ({
|
||||
fetchExportAnnotationList: jest.fn(),
|
||||
clearAllAnnotations: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/components/billing/annotation-full', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="annotation-full" />,
|
||||
}))
|
||||
|
||||
type HeaderOptionsProps = ComponentProps<typeof HeaderOptions>
|
||||
|
||||
const renderComponent = (
|
||||
props: Partial<HeaderOptionsProps> = {},
|
||||
locale: string = LanguagesSupported[0] as string,
|
||||
) => {
|
||||
const defaultProps: HeaderOptionsProps = {
|
||||
appId: 'test-app-id',
|
||||
onAdd: jest.fn(),
|
||||
onAdded: jest.fn(),
|
||||
controlUpdateList: 0,
|
||||
...props,
|
||||
}
|
||||
|
||||
return render(
|
||||
<I18NContext.Provider
|
||||
value={{
|
||||
locale,
|
||||
i18n: {},
|
||||
setLocaleOnClient: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<HeaderOptions {...defaultProps} />
|
||||
</I18NContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
const trigger = document.querySelector('button.btn.btn-secondary') as HTMLButtonElement
|
||||
expect(trigger).toBeTruthy()
|
||||
await user.click(trigger)
|
||||
}
|
||||
|
||||
const expandExportMenu = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await openOperationsPopover(user)
|
||||
const exportLabel = await screen.findByText('appAnnotation.table.header.bulkExport')
|
||||
const exportButton = exportLabel.closest('button') as HTMLButtonElement
|
||||
expect(exportButton).toBeTruthy()
|
||||
await user.click(exportButton)
|
||||
}
|
||||
|
||||
const getExportButtons = async () => {
|
||||
const csvLabel = await screen.findByText('CSV')
|
||||
const jsonLabel = await screen.findByText('JSONL')
|
||||
const csvButton = csvLabel.closest('button') as HTMLButtonElement
|
||||
const jsonButton = jsonLabel.closest('button') as HTMLButtonElement
|
||||
expect(csvButton).toBeTruthy()
|
||||
expect(jsonButton).toBeTruthy()
|
||||
return {
|
||||
csvButton,
|
||||
jsonButton,
|
||||
}
|
||||
}
|
||||
|
||||
const clickOperationAction = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
translationKey: string,
|
||||
) => {
|
||||
const label = await screen.findByText(translationKey)
|
||||
const button = label.closest('button') as HTMLButtonElement
|
||||
expect(button).toBeTruthy()
|
||||
await user.click(button)
|
||||
}
|
||||
|
||||
const mockAnnotations: AnnotationItemBasic[] = [
|
||||
{
|
||||
question: 'Question 1',
|
||||
answer: 'Answer 1',
|
||||
},
|
||||
]
|
||||
|
||||
const mockedFetchAnnotations = jest.mocked(fetchExportAnnotationList)
|
||||
const mockedClearAllAnnotations = jest.mocked(clearAllAnnotations)
|
||||
|
||||
describe('HeaderOptions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.useRealTimers()
|
||||
mockCSVDownloader.mockClear()
|
||||
lastCSVDownloaderProps = undefined
|
||||
mockedFetchAnnotations.mockResolvedValue({ data: [] })
|
||||
})
|
||||
|
||||
it('should fetch annotations on mount and render enabled export actions when data exist', async () => {
|
||||
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedFetchAnnotations).toHaveBeenCalledWith('test-app-id')
|
||||
})
|
||||
|
||||
await expandExportMenu(user)
|
||||
|
||||
const { csvButton, jsonButton } = await getExportButtons()
|
||||
|
||||
expect(csvButton).not.toBeDisabled()
|
||||
expect(jsonButton).not.toBeDisabled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastCSVDownloaderProps).toMatchObject({
|
||||
bom: true,
|
||||
filename: 'annotations-en-US',
|
||||
type: 'link',
|
||||
data: [
|
||||
['Question', 'Answer'],
|
||||
['Question 1', 'Answer 1'],
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable export actions when there are no annotations', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await expandExportMenu(user)
|
||||
|
||||
const { csvButton, jsonButton } = await getExportButtons()
|
||||
|
||||
expect(csvButton).toBeDisabled()
|
||||
expect(jsonButton).toBeDisabled()
|
||||
|
||||
expect(lastCSVDownloaderProps).toMatchObject({
|
||||
data: [['Question', 'Answer']],
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the add annotation modal and forward the onAdd callback', async () => {
|
||||
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
|
||||
const user = userEvent.setup()
|
||||
const onAdd = jest.fn().mockResolvedValue(undefined)
|
||||
renderComponent({ onAdd })
|
||||
|
||||
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalled())
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'appAnnotation.table.header.addAnnotation' }),
|
||||
)
|
||||
|
||||
await screen.findByText('appAnnotation.addModal.title')
|
||||
const questionInput = screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')
|
||||
const answerInput = screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')
|
||||
|
||||
await user.type(questionInput, 'Integration question')
|
||||
await user.type(answerInput, 'Integration answer')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onAdd).toHaveBeenCalledWith({
|
||||
question: 'Integration question',
|
||||
answer: 'Integration answer',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow bulk import through the batch modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAdded = jest.fn()
|
||||
renderComponent({ onAdded })
|
||||
|
||||
await openOperationsPopover(user)
|
||||
await clickOperationAction(user, 'appAnnotation.table.header.bulkImport')
|
||||
|
||||
expect(await screen.findByText('appAnnotation.batchModal.title')).toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'appAnnotation.batchModal.cancel' }),
|
||||
)
|
||||
expect(onAdded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should trigger JSONL download with locale-specific filename', async () => {
|
||||
mockedFetchAnnotations.mockResolvedValue({ data: mockAnnotations })
|
||||
const user = userEvent.setup()
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
const anchor = originalCreateElement('a') as HTMLAnchorElement
|
||||
const clickSpy = jest.spyOn(anchor, 'click').mockImplementation(jest.fn())
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tagName: Parameters<Document['createElement']>[0]) => {
|
||||
if (tagName === 'a')
|
||||
return anchor
|
||||
return originalCreateElement(tagName)
|
||||
})
|
||||
const objectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob://mock-url')
|
||||
const revokeSpy = jest.spyOn(URL, 'revokeObjectURL').mockImplementation(jest.fn())
|
||||
|
||||
renderComponent({}, LanguagesSupported[1] as string)
|
||||
|
||||
await expandExportMenu(user)
|
||||
|
||||
await waitFor(() => expect(mockCSVDownloader).toHaveBeenCalled())
|
||||
|
||||
const { jsonButton } = await getExportButtons()
|
||||
await user.click(jsonButton)
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalled()
|
||||
expect(anchor.download).toBe(`annotations-${LanguagesSupported[1]}.jsonl`)
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(revokeSpy).toHaveBeenCalledWith('blob://mock-url')
|
||||
|
||||
const blobArg = objectURLSpy.mock.calls[0][0] as Blob
|
||||
await expect(blobArg.text()).resolves.toContain('"Question 1"')
|
||||
|
||||
clickSpy.mockRestore()
|
||||
createElementSpy.mockRestore()
|
||||
objectURLSpy.mockRestore()
|
||||
revokeSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should clear all annotations when confirmation succeeds', async () => {
|
||||
mockedClearAllAnnotations.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
const onAdded = jest.fn()
|
||||
renderComponent({ onAdded })
|
||||
|
||||
await openOperationsPopover(user)
|
||||
await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
|
||||
|
||||
await screen.findByText('appAnnotation.table.header.clearAllConfirm')
|
||||
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
|
||||
await user.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedClearAllAnnotations).toHaveBeenCalledWith('test-app-id')
|
||||
expect(onAdded).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle clear all failures gracefully', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn())
|
||||
mockedClearAllAnnotations.mockRejectedValue(new Error('network'))
|
||||
const user = userEvent.setup()
|
||||
const onAdded = jest.fn()
|
||||
renderComponent({ onAdded })
|
||||
|
||||
await openOperationsPopover(user)
|
||||
await clickOperationAction(user, 'appAnnotation.table.header.clearAll')
|
||||
await screen.findByText('appAnnotation.table.header.clearAllConfirm')
|
||||
const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' })
|
||||
await user.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedClearAllAnnotations).toHaveBeenCalled()
|
||||
expect(onAdded).not.toHaveBeenCalled()
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should refetch annotations when controlUpdateList changes', async () => {
|
||||
const view = renderComponent({ controlUpdateList: 0 })
|
||||
|
||||
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1))
|
||||
|
||||
view.rerender(
|
||||
<I18NContext.Provider
|
||||
value={{
|
||||
locale: LanguagesSupported[0] as string,
|
||||
i18n: {},
|
||||
setLocaleOnClient: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<HeaderOptions
|
||||
appId="test-app-id"
|
||||
onAdd={jest.fn()}
|
||||
onAdded={jest.fn()}
|
||||
controlUpdateList={1}
|
||||
/>
|
||||
</I18NContext.Provider>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ import Button from '../../../base/button'
|
||||
import AddAnnotationModal from '../add-annotation-modal'
|
||||
import type { AnnotationItemBasic } from '../type'
|
||||
import BatchAddModal from '../batch-add-annotation-modal'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import CustomPopover from '@/app/components/base/popover'
|
||||
import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
|
||||
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import React from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Annotation from './index'
|
||||
import type { AnnotationItem } from './type'
|
||||
import { JobStatus } from './type'
|
||||
import { type App, AppModeEnum } from '@/types/app'
|
||||
import {
|
||||
addAnnotation,
|
||||
delAnnotation,
|
||||
delAnnotations,
|
||||
fetchAnnotationConfig,
|
||||
fetchAnnotationList,
|
||||
queryAnnotationJobStatus,
|
||||
} from '@/service/annotation'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
__esModule: true,
|
||||
default: { notify: jest.fn() },
|
||||
}))
|
||||
|
||||
jest.mock('ahooks', () => ({
|
||||
useDebounce: (value: any) => value,
|
||||
}))
|
||||
|
||||
jest.mock('@/service/annotation', () => ({
|
||||
addAnnotation: jest.fn(),
|
||||
delAnnotation: jest.fn(),
|
||||
delAnnotations: jest.fn(),
|
||||
fetchAnnotationConfig: jest.fn(),
|
||||
editAnnotation: jest.fn(),
|
||||
fetchAnnotationList: jest.fn(),
|
||||
queryAnnotationJobStatus: jest.fn(),
|
||||
updateAnnotationScore: jest.fn(),
|
||||
updateAnnotationStatus: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('./filter', () => ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="filter">{children}</div>
|
||||
))
|
||||
|
||||
jest.mock('./empty-element', () => () => <div data-testid="empty-element" />)
|
||||
|
||||
jest.mock('./header-opts', () => (props: any) => (
|
||||
<div data-testid="header-opts">
|
||||
<button data-testid="trigger-add" onClick={() => props.onAdd({ question: 'new question', answer: 'new answer' })}>
|
||||
add
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
|
||||
let latestListProps: any
|
||||
|
||||
jest.mock('./list', () => (props: any) => {
|
||||
latestListProps = props
|
||||
if (!props.list.length)
|
||||
return <div data-testid="list-empty" />
|
||||
return (
|
||||
<div data-testid="list">
|
||||
<button data-testid="list-view" onClick={() => props.onView(props.list[0])}>view</button>
|
||||
<button data-testid="list-remove" onClick={() => props.onRemove(props.list[0].id)}>remove</button>
|
||||
<button data-testid="list-batch-delete" onClick={() => props.onBatchDelete()}>batch-delete</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
jest.mock('./view-annotation-modal', () => (props: any) => {
|
||||
if (!props.isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="view-modal">
|
||||
<div>{props.item.question}</div>
|
||||
<button data-testid="view-modal-remove" onClick={props.onRemove}>remove</button>
|
||||
<button data-testid="view-modal-close" onClick={props.onHide}>close</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
jest.mock('@/app/components/base/pagination', () => () => <div data-testid="pagination" />)
|
||||
jest.mock('@/app/components/base/loading', () => () => <div data-testid="loading" />)
|
||||
jest.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => (props: any) => props.isShow ? <div data-testid="config-modal" /> : null)
|
||||
jest.mock('@/app/components/billing/annotation-full/modal', () => (props: any) => props.show ? <div data-testid="annotation-full-modal" /> : null)
|
||||
|
||||
const mockNotify = Toast.notify as jest.Mock
|
||||
const addAnnotationMock = addAnnotation as jest.Mock
|
||||
const delAnnotationMock = delAnnotation as jest.Mock
|
||||
const delAnnotationsMock = delAnnotations as jest.Mock
|
||||
const fetchAnnotationConfigMock = fetchAnnotationConfig as jest.Mock
|
||||
const fetchAnnotationListMock = fetchAnnotationList as jest.Mock
|
||||
const queryAnnotationJobStatusMock = queryAnnotationJobStatus as jest.Mock
|
||||
const useProviderContextMock = useProviderContext as jest.Mock
|
||||
|
||||
const appDetail = {
|
||||
id: 'app-id',
|
||||
mode: AppModeEnum.CHAT,
|
||||
} as App
|
||||
|
||||
const createAnnotation = (overrides: Partial<AnnotationItem> = {}): AnnotationItem => ({
|
||||
id: overrides.id ?? 'annotation-1',
|
||||
question: overrides.question ?? 'Question 1',
|
||||
answer: overrides.answer ?? 'Answer 1',
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
hit_count: overrides.hit_count ?? 0,
|
||||
})
|
||||
|
||||
const renderComponent = () => render(<Annotation appDetail={appDetail} />)
|
||||
|
||||
describe('Annotation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
latestListProps = undefined
|
||||
fetchAnnotationConfigMock.mockResolvedValue({
|
||||
id: 'config-id',
|
||||
enabled: false,
|
||||
embedding_model: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
},
|
||||
score_threshold: 0.5,
|
||||
})
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 })
|
||||
queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed })
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render empty element when no annotations are returned', async () => {
|
||||
renderComponent()
|
||||
|
||||
expect(await screen.findByTestId('empty-element')).toBeInTheDocument()
|
||||
expect(fetchAnnotationListMock).toHaveBeenCalledWith(appDetail.id, expect.objectContaining({
|
||||
page: 1,
|
||||
keyword: '',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle annotation creation and refresh list data', async () => {
|
||||
const annotation = createAnnotation()
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
|
||||
addAnnotationMock.mockResolvedValue(undefined)
|
||||
|
||||
renderComponent()
|
||||
|
||||
await screen.findByTestId('list')
|
||||
fireEvent.click(screen.getByTestId('trigger-add'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addAnnotationMock).toHaveBeenCalledWith(appDetail.id, { question: 'new question', answer: 'new answer' })
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'common.api.actionSuccess',
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
expect(fetchAnnotationListMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should support viewing items and running batch deletion success flow', async () => {
|
||||
const annotation = createAnnotation()
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
|
||||
delAnnotationsMock.mockResolvedValue(undefined)
|
||||
delAnnotationMock.mockResolvedValue(undefined)
|
||||
|
||||
renderComponent()
|
||||
await screen.findByTestId('list')
|
||||
|
||||
await act(async () => {
|
||||
latestListProps.onSelectedIdsChange([annotation.id])
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(latestListProps.selectedIds).toEqual([annotation.id])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await latestListProps.onBatchDelete()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(delAnnotationsMock).toHaveBeenCalledWith(appDetail.id, [annotation.id])
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
}))
|
||||
expect(latestListProps.selectedIds).toEqual([])
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('list-view'))
|
||||
expect(screen.getByTestId('view-modal')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('view-modal-remove'))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(delAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error notification when batch deletion fails', async () => {
|
||||
const annotation = createAnnotation()
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
|
||||
const error = new Error('failed')
|
||||
delAnnotationsMock.mockRejectedValue(error)
|
||||
|
||||
renderComponent()
|
||||
await screen.findByTestId('list')
|
||||
|
||||
await act(async () => {
|
||||
latestListProps.onSelectedIdsChange([annotation.id])
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(latestListProps.selectedIds).toEqual([annotation.id])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await latestListProps.onBatchDelete()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
})
|
||||
expect(latestListProps.selectedIds).toEqual([annotation.id])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -25,7 +25,7 @@ import { sleep } from '@/utils'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import { type App, AppModeEnum } from '@/types/app'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { delAnnotations } from '@/service/annotation'
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import List from './list'
|
||||
import type { AnnotationItem } from './type'
|
||||
|
||||
const mockFormatTime = jest.fn(() => 'formatted-time')
|
||||
|
||||
jest.mock('@/hooks/use-timestamp', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
formatTime: mockFormatTime,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createAnnotation = (overrides: Partial<AnnotationItem> = {}): AnnotationItem => ({
|
||||
id: overrides.id ?? 'annotation-id',
|
||||
question: overrides.question ?? 'question 1',
|
||||
answer: overrides.answer ?? 'answer 1',
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
hit_count: overrides.hit_count ?? 2,
|
||||
})
|
||||
|
||||
const getCheckboxes = (container: HTMLElement) => container.querySelectorAll('[data-testid^="checkbox"]')
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render annotation rows and call onView when clicking a row', () => {
|
||||
const item = createAnnotation()
|
||||
const onView = jest.fn()
|
||||
|
||||
render(
|
||||
<List
|
||||
list={[item]}
|
||||
onView={onView}
|
||||
onRemove={jest.fn()}
|
||||
selectedIds={[]}
|
||||
onSelectedIdsChange={jest.fn()}
|
||||
onBatchDelete={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(item.question))
|
||||
|
||||
expect(onView).toHaveBeenCalledWith(item)
|
||||
expect(mockFormatTime).toHaveBeenCalledWith(item.created_at, 'appLog.dateTimeFormat')
|
||||
})
|
||||
|
||||
it('should toggle single and bulk selection states', () => {
|
||||
const list = [createAnnotation({ id: 'a', question: 'A' }), createAnnotation({ id: 'b', question: 'B' })]
|
||||
const onSelectedIdsChange = jest.fn()
|
||||
const { container, rerender } = render(
|
||||
<List
|
||||
list={list}
|
||||
onView={jest.fn()}
|
||||
onRemove={jest.fn()}
|
||||
selectedIds={[]}
|
||||
onSelectedIdsChange={onSelectedIdsChange}
|
||||
onBatchDelete={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
fireEvent.click(checkboxes[1])
|
||||
expect(onSelectedIdsChange).toHaveBeenCalledWith(['a'])
|
||||
|
||||
rerender(
|
||||
<List
|
||||
list={list}
|
||||
onView={jest.fn()}
|
||||
onRemove={jest.fn()}
|
||||
selectedIds={['a']}
|
||||
onSelectedIdsChange={onSelectedIdsChange}
|
||||
onBatchDelete={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
const updatedCheckboxes = getCheckboxes(container)
|
||||
fireEvent.click(updatedCheckboxes[1])
|
||||
expect(onSelectedIdsChange).toHaveBeenCalledWith([])
|
||||
|
||||
fireEvent.click(updatedCheckboxes[0])
|
||||
expect(onSelectedIdsChange).toHaveBeenCalledWith(['a', 'b'])
|
||||
})
|
||||
|
||||
it('should confirm before removing an annotation and expose batch actions', async () => {
|
||||
const item = createAnnotation({ id: 'to-delete', question: 'Delete me' })
|
||||
const onRemove = jest.fn()
|
||||
render(
|
||||
<List
|
||||
list={[item]}
|
||||
onView={jest.fn()}
|
||||
onRemove={onRemove}
|
||||
selectedIds={[item.id]}
|
||||
onSelectedIdsChange={jest.fn()}
|
||||
onBatchDelete={jest.fn()}
|
||||
onCancel={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const row = screen.getByText(item.question).closest('tr') as HTMLTableRowElement
|
||||
const actionButtons = within(row).getAllByRole('button')
|
||||
fireEvent.click(actionButtons[1])
|
||||
|
||||
expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
|
||||
const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
|
||||
fireEvent.click(confirmButton)
|
||||
expect(onRemove).toHaveBeenCalledWith(item.id)
|
||||
|
||||
expect(screen.getByText('appAnnotation.batchAction.selected')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import type { AnnotationItem } from './type'
|
||||
import RemoveAnnotationConfirmModal from './remove-annotation-confirm-modal'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import BatchAction from './batch-action'
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ export type AnnotationItem = {
|
||||
hit_count: number
|
||||
}
|
||||
|
||||
export type AnnotationCreateResponse = AnnotationItem & {
|
||||
account?: {
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type HitHistoryItem = {
|
||||
id: string
|
||||
question: string
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ViewAnnotationModal from './index'
|
||||
import type { AnnotationItem, HitHistoryItem } from '../type'
|
||||
import { fetchHitHistoryList } from '@/service/annotation'
|
||||
|
||||
const mockFormatTime = jest.fn(() => 'formatted-time')
|
||||
|
||||
jest.mock('@/hooks/use-timestamp', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
formatTime: mockFormatTime,
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/annotation', () => ({
|
||||
fetchHitHistoryList: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('../edit-annotation-modal/edit-item', () => {
|
||||
const EditItemType = {
|
||||
Query: 'query',
|
||||
Answer: 'answer',
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ type, content, onSave }: { type: string; content: string; onSave: (value: string) => void }) => (
|
||||
<div>
|
||||
<div data-testid={`content-${type}`}>{content}</div>
|
||||
<button data-testid={`edit-${type}`} onClick={() => onSave(`${type}-updated`)}>edit-{type}</button>
|
||||
</div>
|
||||
),
|
||||
EditItemType,
|
||||
}
|
||||
})
|
||||
|
||||
const fetchHitHistoryListMock = fetchHitHistoryList as jest.Mock
|
||||
|
||||
const createAnnotationItem = (overrides: Partial<AnnotationItem> = {}): AnnotationItem => ({
|
||||
id: overrides.id ?? 'annotation-id',
|
||||
question: overrides.question ?? 'question',
|
||||
answer: overrides.answer ?? 'answer',
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
hit_count: overrides.hit_count ?? 0,
|
||||
})
|
||||
|
||||
const createHitHistoryItem = (overrides: Partial<HitHistoryItem> = {}): HitHistoryItem => ({
|
||||
id: overrides.id ?? 'hit-id',
|
||||
question: overrides.question ?? 'query',
|
||||
match: overrides.match ?? 'match',
|
||||
response: overrides.response ?? 'response',
|
||||
source: overrides.source ?? 'source',
|
||||
score: overrides.score ?? 0.42,
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
})
|
||||
|
||||
const renderComponent = (props?: Partial<React.ComponentProps<typeof ViewAnnotationModal>>) => {
|
||||
const item = createAnnotationItem()
|
||||
const mergedProps: React.ComponentProps<typeof ViewAnnotationModal> = {
|
||||
appId: 'app-id',
|
||||
isShow: true,
|
||||
onHide: jest.fn(),
|
||||
item,
|
||||
onSave: jest.fn().mockResolvedValue(undefined),
|
||||
onRemove: jest.fn().mockResolvedValue(undefined),
|
||||
...props,
|
||||
}
|
||||
return {
|
||||
...render(<ViewAnnotationModal {...mergedProps} />),
|
||||
props: mergedProps,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ViewAnnotationModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
fetchHitHistoryListMock.mockResolvedValue({ data: [], total: 0 })
|
||||
})
|
||||
|
||||
it('should render annotation tab and allow saving updated query', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchHitHistoryListMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('edit-query'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledWith('query-updated', props.item.answer)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render annotation tab and allow saving updated answer', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchHitHistoryListMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('edit-answer'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(props.onSave).toHaveBeenCalledWith(props.item.question, 'answer-updated')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('should switch to hit history tab and show no data message', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchHitHistoryListMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('appAnnotation.viewModal.hitHistory'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument()
|
||||
expect(mockFormatTime).toHaveBeenCalledWith(props.item.created_at, 'appLog.dateTimeFormat')
|
||||
})
|
||||
|
||||
it('should render hit history entries with pagination badge when data exists', async () => {
|
||||
const hits = [createHitHistoryItem({ question: 'user input' }), createHitHistoryItem({ id: 'hit-2', question: 'second' })]
|
||||
fetchHitHistoryListMock.mockResolvedValue({ data: hits, total: 15 })
|
||||
|
||||
renderComponent()
|
||||
|
||||
fireEvent.click(await screen.findByText('appAnnotation.viewModal.hitHistory'))
|
||||
|
||||
expect(await screen.findByText('user input')).toBeInTheDocument()
|
||||
expect(screen.getByText('15 appAnnotation.viewModal.hits')).toBeInTheDocument()
|
||||
expect(mockFormatTime).toHaveBeenCalledWith(hits[0].created_at, 'appLog.dateTimeFormat')
|
||||
})
|
||||
|
||||
it('should confirm before removing the annotation and hide on success', async () => {
|
||||
const { props } = renderComponent()
|
||||
|
||||
fireEvent.click(screen.getByText('appAnnotation.editModal.removeThisCache'))
|
||||
expect(await screen.findByText('appDebug.feature.annotation.removeConfirm')).toBeInTheDocument()
|
||||
|
||||
const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onRemove).toHaveBeenCalledTimes(1)
|
||||
expect(props.onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import TabSlider from '@/app/components/base/tab-slider-plain'
|
||||
import { fetchHitHistoryList } from '@/service/annotation'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
appId: string
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Fragment, useCallback } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type DialogProps = {
|
||||
className?: string
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import AccessControl from './index'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
import SpecificGroupsOrMembers from './specific-groups-or-members'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import Toast from '../../base/toast'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import type { App } from '@/types/app'
|
||||
|
||||
const mockUseAppWhiteListSubjects = jest.fn()
|
||||
const mockUseSearchForWhiteListCandidates = jest.fn()
|
||||
const mockMutateAsync = jest.fn()
|
||||
const mockUseUpdateAccessMode = jest.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
}))
|
||||
|
||||
jest.mock('@/context/app-context', () => ({
|
||||
useSelector: <T,>(selector: (value: { userProfile: { email: string; id?: string; name?: string; avatar?: string; avatar_url?: string; is_password_set?: boolean } }) => T) => selector({
|
||||
userProfile: {
|
||||
id: 'current-user',
|
||||
name: 'Current User',
|
||||
email: '[email protected]',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: true,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/common', () => ({
|
||||
fetchCurrentWorkspace: jest.fn(),
|
||||
fetchLangGeniusVersion: jest.fn(),
|
||||
fetchUserProfile: jest.fn(),
|
||||
getSystemFeatures: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args),
|
||||
useUpdateAccessMode: () => mockUseUpdateAccessMode(),
|
||||
}))
|
||||
|
||||
jest.mock('@headlessui/react', () => {
|
||||
const DialogComponent: any = ({ children, className, ...rest }: any) => (
|
||||
<div role="dialog" className={className} {...rest}>{children}</div>
|
||||
)
|
||||
DialogComponent.Panel = ({ children, className, ...rest }: any) => (
|
||||
<div className={className} {...rest}>{children}</div>
|
||||
)
|
||||
const DialogTitle = ({ children, className, ...rest }: any) => (
|
||||
<div className={className} {...rest}>{children}</div>
|
||||
)
|
||||
const DialogDescription = ({ children, className, ...rest }: any) => (
|
||||
<div className={className} {...rest}>{children}</div>
|
||||
)
|
||||
const TransitionChild = ({ children }: any) => (
|
||||
<>{typeof children === 'function' ? children({}) : children}</>
|
||||
)
|
||||
const Transition = ({ show = true, children }: any) => (
|
||||
show ? <>{typeof children === 'function' ? children({}) : children}</> : null
|
||||
)
|
||||
Transition.Child = TransitionChild
|
||||
return {
|
||||
Dialog: DialogComponent,
|
||||
Transition,
|
||||
DialogTitle,
|
||||
Description: DialogDescription,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('ahooks', () => {
|
||||
const actual = jest.requireActual('ahooks')
|
||||
return {
|
||||
...actual,
|
||||
useDebounce: (value: unknown) => value,
|
||||
}
|
||||
})
|
||||
|
||||
const createGroup = (overrides: Partial<AccessControlGroup> = {}): AccessControlGroup => ({
|
||||
id: 'group-1',
|
||||
name: 'Group One',
|
||||
groupSize: 5,
|
||||
...overrides,
|
||||
} as AccessControlGroup)
|
||||
|
||||
const createMember = (overrides: Partial<AccessControlAccount> = {}): AccessControlAccount => ({
|
||||
id: 'member-1',
|
||||
name: 'Member One',
|
||||
email: '[email protected]',
|
||||
avatar: '',
|
||||
avatarUrl: '',
|
||||
...overrides,
|
||||
} as AccessControlAccount)
|
||||
|
||||
const baseGroup = createGroup()
|
||||
const baseMember = createMember()
|
||||
const groupSubject: Subject = {
|
||||
subjectId: baseGroup.id,
|
||||
subjectType: SubjectType.GROUP,
|
||||
groupData: baseGroup,
|
||||
} as Subject
|
||||
const memberSubject: Subject = {
|
||||
subjectId: baseMember.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
accountData: baseMember,
|
||||
} as Subject
|
||||
|
||||
const resetAccessControlStore = () => {
|
||||
useAccessControlStore.setState({
|
||||
appId: '',
|
||||
specificGroups: [],
|
||||
specificMembers: [],
|
||||
currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
selectedGroupsForBreadcrumb: [],
|
||||
})
|
||||
}
|
||||
|
||||
const resetGlobalStore = () => {
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: defaultSystemFeatures,
|
||||
isGlobalPending: false,
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
class MockIntersectionObserver {
|
||||
observe = jest.fn(() => undefined)
|
||||
disconnect = jest.fn(() => undefined)
|
||||
unobserve = jest.fn(() => undefined)
|
||||
}
|
||||
// @ts-expect-error jsdom does not implement IntersectionObserver
|
||||
globalThis.IntersectionObserver = MockIntersectionObserver
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
resetAccessControlStore()
|
||||
resetGlobalStore()
|
||||
mockMutateAsync.mockResolvedValue(undefined)
|
||||
mockUseUpdateAccessMode.mockReturnValue({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
})
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: false,
|
||||
data: {
|
||||
groups: [baseGroup],
|
||||
members: [baseMember],
|
||||
},
|
||||
})
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: jest.fn(),
|
||||
data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }] },
|
||||
})
|
||||
})
|
||||
|
||||
// AccessControlItem handles selected vs. unselected styling and click state updates
|
||||
describe('AccessControlItem', () => {
|
||||
it('should update current menu when selecting a different access type', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.PUBLIC })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
expect(option).toHaveClass('cursor-pointer')
|
||||
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
|
||||
it('should keep current menu when clicking the selected access type', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
render(
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<span>Organization Only</span>
|
||||
</AccessControlItem>,
|
||||
)
|
||||
|
||||
const option = screen.getByText('Organization Only').parentElement as HTMLElement
|
||||
fireEvent.click(option)
|
||||
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION)
|
||||
})
|
||||
})
|
||||
|
||||
// AccessControlDialog renders a headless UI dialog with a manual close control
|
||||
describe('AccessControlDialog', () => {
|
||||
it('should render dialog content when visible', () => {
|
||||
render(
|
||||
<AccessControlDialog show className="custom-dialog">
|
||||
<div>Dialog Content</div>
|
||||
</AccessControlDialog>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dialog Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger onClose when clicking the close control', async () => {
|
||||
const handleClose = jest.fn()
|
||||
const { container } = render(
|
||||
<AccessControlDialog show onClose={handleClose}>
|
||||
<div>Dialog Content</div>
|
||||
</AccessControlDialog>,
|
||||
)
|
||||
|
||||
const closeButton = container.querySelector('.absolute.right-5.top-5') as HTMLElement
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// SpecificGroupsOrMembers syncs store state with fetched data and supports removals
|
||||
describe('SpecificGroupsOrMembers', () => {
|
||||
it('should render collapsed view when not in specific selection mode', () => {
|
||||
useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION })
|
||||
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
|
||||
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading state while pending', async () => {
|
||||
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
|
||||
mockUseAppWhiteListSubjects.mockReturnValue({
|
||||
isPending: true,
|
||||
data: undefined,
|
||||
})
|
||||
|
||||
const { container } = render(<SpecificGroupsOrMembers />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render fetched groups and members and support removal', async () => {
|
||||
useAccessControlStore.setState({ appId: 'app-1', currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS })
|
||||
|
||||
render(<SpecificGroupsOrMembers />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const groupItem = screen.getByText(baseGroup.name).closest('div')
|
||||
const groupRemove = groupItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
|
||||
fireEvent.click(groupRemove)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
const memberItem = screen.getByText(baseMember.name).closest('div')
|
||||
const memberRemove = memberItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement
|
||||
fireEvent.click(memberRemove)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(baseMember.name)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// AddMemberOrGroupDialog renders search results and updates store selections
|
||||
describe('AddMemberOrGroupDialog', () => {
|
||||
it('should open search popover and display candidates', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByText(baseGroup.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(baseMember.name)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow selecting members and expanding groups', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')
|
||||
await user.click(expandButton)
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup])
|
||||
|
||||
const memberLabel = screen.getByText(baseMember.name)
|
||||
const memberCheckbox = memberLabel.parentElement?.previousElementSibling as HTMLElement
|
||||
fireEvent.click(memberCheckbox)
|
||||
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
|
||||
})
|
||||
|
||||
it('should show empty state when no candidates are returned', async () => {
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: jest.fn(),
|
||||
data: { pages: [] },
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
|
||||
expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// AccessControl integrates dialog, selection items, and confirm flow
|
||||
describe('AccessControl', () => {
|
||||
it('should initialize menu from app and call update on confirm', async () => {
|
||||
const onClose = jest.fn()
|
||||
const onConfirm = jest.fn()
|
||||
const toastSpy = jest.spyOn(Toast, 'notify').mockReturnValue({})
|
||||
useAccessControlStore.setState({
|
||||
specificGroups: [baseGroup],
|
||||
specificMembers: [baseMember],
|
||||
})
|
||||
const app = {
|
||||
id: 'app-id-1',
|
||||
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
} as App
|
||||
|
||||
render(
|
||||
<AccessControl
|
||||
app={app}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
subjects: [
|
||||
{ subjectId: baseGroup.id, subjectType: SubjectType.GROUP },
|
||||
{ subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT },
|
||||
],
|
||||
})
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(onConfirm).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose the external members tip when SSO is disabled', () => {
|
||||
const app = {
|
||||
id: 'app-id-2',
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
} as App
|
||||
|
||||
render(
|
||||
<AccessControl
|
||||
app={app}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import Input from '../../base/input'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||
import Loading from '../../base/loading'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
@@ -32,7 +32,7 @@ export default function AddMemberOrGroupDialog() {
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const hasMore = data?.pages?.[0].hasMore ?? false
|
||||
const hasMore = data?.pages?.[0]?.hasMore ?? false
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
@@ -106,7 +106,7 @@ function SelectedGroupsBreadCrumb() {
|
||||
setSelectedGroupsForBreadcrumb([])
|
||||
}, [setSelectedGroupsForBreadcrumb])
|
||||
return <div className='flex h-7 items-center gap-x-0.5 px-2 py-0.5'>
|
||||
<span className={classNames('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
|
||||
<span className={cn('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
|
||||
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||
return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'>
|
||||
<span>/</span>
|
||||
@@ -198,7 +198,7 @@ type BaseItemProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
function BaseItem({ children, className }: BaseItemProps) {
|
||||
return <div className={classNames('flex cursor-pointer items-center space-x-2 p-1 pl-2 hover:rounded-lg hover:bg-state-base-hover', className)}>
|
||||
return <div className={cn('flex cursor-pointer items-center space-x-2 p-1 pl-2 hover:rounded-lg hover:bg-state-base-hover', className)}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HTMLProps, PropsWithChildren } from 'react'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement> & {
|
||||
icon?: React.ReactNode
|
||||
@@ -19,11 +19,9 @@ const SuggestedAction = ({ icon, link, disabled, children, className, onClick, .
|
||||
href={disabled ? undefined : link}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={classNames(
|
||||
'flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors [&:not(:first-child)]:mt-1',
|
||||
className={cn('flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors [&:not(:first-child)]:mt-1',
|
||||
disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer text-text-secondary hover:bg-state-accent-hover hover:text-text-accent',
|
||||
className,
|
||||
)}
|
||||
className)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type IFeaturePanelProps = {
|
||||
className?: string
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
RiAddLine,
|
||||
RiEditLine,
|
||||
} from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type IOperationBtnProps = {
|
||||
|
||||
@@ -14,7 +14,7 @@ import s from './style.module.css'
|
||||
import MessageTypeSelector from './message-type-selector'
|
||||
import ConfirmAddVar from './confirm-add-var'
|
||||
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import type { PromptRole, PromptVariable } from '@/models/debug'
|
||||
import {
|
||||
Copy,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useBoolean, useClickAway } from 'ahooks'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { PromptRole } from '@/models/debug'
|
||||
import { ChevronSelectorVertical } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
type Props = {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import cn from '@/utils/classnames'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user