From ebf214b679c123b1a3b6e5eefa57cff59baefb3b Mon Sep 17 00:00:00 2001 From: Emilio Righi Date: Wed, 12 Aug 2026 09:51:52 +0200 Subject: [PATCH 1/5] add assembly related fields to TSV download --- annotrieve-api-specs.yaml | 711 +++++++++++++++++- .../annotations/download-tsv-dialog.tsx | 41 +- front/public/annotrieve-api-specs.yaml | 711 +++++++++++++++++- server/helpers/tsv_fields.py | 113 ++- server/services/annotations_service.py | 15 +- 5 files changed, 1567 insertions(+), 24 deletions(-) diff --git a/annotrieve-api-specs.yaml b/annotrieve-api-specs.yaml index 487e319..4eba63c 100644 --- a/annotrieve-api-specs.yaml +++ b/annotrieve-api-specs.yaml @@ -22,6 +22,8 @@ tags: description: "Taxonomy information endpoints" - name: "bioprojects" description: "BioProject information endpoints" + - name: "analytics" + description: "Public usage analytics endpoints" paths: /annotations: @@ -96,7 +98,7 @@ paths: - "annotations" operationId: "getAnnotationsReport" summary: "Download annotation metadata as TSV" - description: "Streams a TSV file with annotation metadata columns. By default exports annotation_id, assembly_accession, assembly_name, organism_name, taxid, database, provider, source_url, bgzip_path, and csi_path. Use selected_fields to append additional extended columns." + description: "Streams a TSV file with annotation metadata columns. By default exports annotation_id, assembly_accession, assembly_name, organism_name, taxid, database, provider, source_url, bgzip_path, and csi_path. Use selected_fields to append additional extended columns, including assembly-derived fields (assembly_refseq_category, assembly_download_url, assembly_gc_percent) resolved via a join on assembly_accession." parameters: - $ref: "#/components/parameters/filter" - $ref: "#/components/parameters/limit" @@ -138,7 +140,7 @@ paths: - "annotations" operationId: "postAnnotationsReport" summary: "Download annotation metadata via POST" - description: "Same as GET /annotations/report, but accepts filters in the request body. Omit selected_fields to preserve the default production column set; provide it to append extended columns after the defaults." + description: "Same as GET /annotations/report, but accepts filters in the request body. Omit selected_fields to preserve the default production column set; provide it to append extended columns after the defaults, including assembly-derived fields (assembly_refseq_category, assembly_download_url, assembly_gc_percent) resolved via a join on assembly_accession." requestBody: required: true content: @@ -261,6 +263,33 @@ paths: "500": $ref: "#/components/responses/InternalError" + /annotations/aggregates/taxons: + get: + tags: + - "annotations" + operationId: "getAnnotationsAggregatesByTaxonRank" + summary: "Get annotation aggregates grouped by taxon rank" + description: "Returns one record per taxon at the given rank with average coding/non-coding/pseudogene gene counts and annotation count." + parameters: + - name: rank + in: query + required: true + description: "Taxonomic rank to aggregate by." + schema: + type: string + enum: ["domain", "kingdom", "phylum", "class", "order", "family", "genus"] + responses: + "200": + description: "Aggregates by taxon" + content: + application/json: + schema: + $ref: "#/components/schemas/AnnotationsAggregatesByTaxonResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + /annotations/gene-stats: get: tags: @@ -737,6 +766,245 @@ paths: "500": $ref: "#/components/responses/InternalError" + /annotations/busco-stats: + get: + tags: + - "annotations" + operationId: "getBuscoStats" + summary: "Get BUSCO stats summary" + description: "Returns BUSCO stats summary with aggregated statistics for metrics only (complete, single_copy, duplicated, fragmented, missing). No categories." + parameters: + - $ref: "#/components/parameters/filter" + - $ref: "#/components/parameters/taxids" + - $ref: "#/components/parameters/assembly_accessions" + - $ref: "#/components/parameters/bioproject_accessions" + - $ref: "#/components/parameters/db_sources" + - $ref: "#/components/parameters/feature_sources" + - $ref: "#/components/parameters/biotypes" + - $ref: "#/components/parameters/feature_types" + - $ref: "#/components/parameters/pipelines" + - $ref: "#/components/parameters/providers" + - $ref: "#/components/parameters/md5_checksums" + - $ref: "#/components/parameters/has_stats" + - $ref: "#/components/parameters/refseq_categories" + - $ref: "#/components/parameters/assembly_levels" + - $ref: "#/components/parameters/assembly_statuses" + - $ref: "#/components/parameters/assembly_types" + - $ref: "#/components/parameters/release_date_from" + - $ref: "#/components/parameters/release_date_to" + responses: + "200": + description: "BUSCO stats summary" + content: + application/json: + schema: + $ref: "#/components/schemas/BuscoStatsSummaryResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + post: + tags: + - "annotations" + operationId: "postBuscoStats" + summary: "Get BUSCO stats summary via POST" + description: "Same as GET /annotations/busco-stats, but accepts filters in the request body." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AnnotationQueryParams" + responses: + "200": + description: "BUSCO stats summary" + content: + application/json: + schema: + $ref: "#/components/schemas/BuscoStatsSummaryResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + + /annotations/busco-stats/{metric}: + get: + tags: + - "annotations" + operationId: "getBuscoMetricValues" + summary: "Get raw values for a specific BUSCO metric" + description: "Returns raw values for a specific BUSCO metric (for plotting histograms)." + parameters: + - name: metric + in: path + required: true + description: "BUSCO metric name (complete, single_copy, duplicated, fragmented, or missing)" + schema: + type: string + enum: ["complete", "single_copy", "duplicated", "fragmented", "missing"] + - name: include_annotations + in: query + description: "If true, include annotation_ids list in response" + schema: + type: boolean + default: false + - $ref: "#/components/parameters/filter" + - $ref: "#/components/parameters/taxids" + - $ref: "#/components/parameters/assembly_accessions" + - $ref: "#/components/parameters/bioproject_accessions" + - $ref: "#/components/parameters/db_sources" + - $ref: "#/components/parameters/feature_sources" + - $ref: "#/components/parameters/biotypes" + - $ref: "#/components/parameters/feature_types" + - $ref: "#/components/parameters/pipelines" + - $ref: "#/components/parameters/providers" + - $ref: "#/components/parameters/md5_checksums" + - $ref: "#/components/parameters/has_stats" + - $ref: "#/components/parameters/refseq_categories" + - $ref: "#/components/parameters/assembly_levels" + - $ref: "#/components/parameters/assembly_statuses" + - $ref: "#/components/parameters/assembly_types" + - $ref: "#/components/parameters/release_date_from" + - $ref: "#/components/parameters/release_date_to" + responses: + "200": + description: "Metric values" + content: + application/json: + schema: + $ref: "#/components/schemas/BuscoMetricValuesResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + post: + tags: + - "annotations" + operationId: "postBuscoMetricValues" + summary: "Get BUSCO metric values via POST" + description: "Same as GET /annotations/busco-stats/{metric}, but accepts filters in the request body." + parameters: + - name: metric + in: path + required: true + description: "BUSCO metric name (complete, single_copy, duplicated, fragmented, or missing)" + schema: + type: string + enum: ["complete", "single_copy", "duplicated", "fragmented", "missing"] + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/AnnotationQueryParams" + - type: object + properties: + include_annotations: + type: boolean + description: "If true, include annotation_ids list in response" + default: false + responses: + "200": + description: "Metric values" + content: + application/json: + schema: + $ref: "#/components/schemas/BuscoMetricValuesResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + + /annotations/upload-gff: + post: + tags: + - "annotations" + operationId: "uploadCustomGff" + summary: "Upload a custom GFF/GFF3 file" + description: "Upload a custom GFF/GFF3 file and enqueue a background job to compute feature summary and statistics. Subject to a per-client daily rate limit." + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + - custom_name + properties: + file: + type: string + format: binary + description: "GFF/GFF3 file (.gff, .gff3, .gff.gz, or .gff3.gz)" + custom_name: + type: string + description: "Display name for the uploaded annotation" + responses: + "200": + description: "Upload accepted and job enqueued" + content: + application/json: + schema: + $ref: "#/components/schemas/UploadGffResponse" + "400": + $ref: "#/components/responses/BadRequest" + "413": + description: "Uploaded file is too large" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "429": + description: "Daily upload limit reached" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + $ref: "#/components/responses/InternalError" + + /annotations/upload-gff/jobs/{task_id}: + get: + tags: + - "annotations" + operationId: "getUploadGffJobStatus" + summary: "Get custom GFF upload job status" + description: "Returns the status of a custom GFF upload background job." + parameters: + - name: task_id + in: path + required: true + description: "Celery task ID returned by POST /annotations/upload-gff" + schema: + type: string + responses: + "200": + description: "Job status" + content: + application/json: + schema: + $ref: "#/components/schemas/UploadJobStatusResponse" + "500": + $ref: "#/components/responses/InternalError" + + /annotations/upload-gff/rate-limit: + get: + tags: + - "annotations" + operationId: "getUploadGffRateLimit" + summary: "Get custom GFF upload rate-limit status" + description: "Returns how many uploads were used in the last 24 hours and remaining quota for the calling client." + responses: + "200": + description: "Rate-limit status" + content: + application/json: + schema: + $ref: "#/components/schemas/UploadRateLimitResponse" + "500": + $ref: "#/components/responses/InternalError" + /annotations/{md5_checksum}: get: tags: @@ -1296,6 +1564,39 @@ paths: "500": $ref: "#/components/responses/InternalError" + /taxons/flattened-tree: + get: + tags: + - "taxons" + operationId: "getFlattenedTree" + summary: "Get flattened taxonomy tree" + description: "Returns a flattened taxonomy tree. When a prebuilt export exists, responds with a 307 redirect to the static file under /annotrieve/files/taxonomy/. Otherwise returns an on-the-fly aggregation as JSON ({fields, rows}) or a TSV stream depending on format." + parameters: + - name: format + in: query + description: "Response format: json (default) or tsv." + schema: + type: string + enum: ["json", "tsv"] + default: "json" + responses: + "200": + description: "Flattened taxonomy tree" + content: + application/json: + schema: + $ref: "#/components/schemas/FlattenedTreeResponse" + text/tab-separated-values: + schema: + type: string + description: "TSV payload streamed line by line" + "307": + description: "Redirect to prebuilt static flattened-tree file when available" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + /taxons/{taxid}: get: tags: @@ -1454,6 +1755,127 @@ paths: "500": $ref: "#/components/responses/InternalError" + /analytics/frequencies/country: + get: + tags: + - "analytics" + operationId: "getCountryFrequencies" + summary: "Get unique users by country" + description: "Returns frequency counts of unique users by country. Each user is identified by an anonymous fingerprint (HMAC of IP)." + responses: + "200": + description: "Country frequency counts" + content: + application/json: + schema: + $ref: "#/components/schemas/FrequencyCounts" + "500": + $ref: "#/components/responses/InternalError" + + /analytics/top-visitors: + get: + tags: + - "analytics" + operationId: "getTopVisitors" + summary: "Get top anonymous visitors by visit days" + deprecated: true + description: "Top anonymous visitors by distinct visit days. Prefer /analytics/top-countries for public usage UI. Never returns fingerprints or IPs." + parameters: + - name: limit + in: query + description: "Maximum number of visitors to return (capped at 5)" + schema: + type: integer + minimum: 1 + maximum: 5 + default: 5 + responses: + "200": + description: "Top visitors" + content: + application/json: + schema: + $ref: "#/components/schemas/TopVisitorsResponse" + "500": + $ref: "#/components/responses/InternalError" + + /analytics/summary: + get: + tags: + - "analytics" + operationId: "getUsageSummary" + summary: "Get public usage summary metrics" + description: "Public usage hero metrics from UserAnalytics (API-activity users). Never returns fingerprints or IPs." + responses: + "200": + description: "Usage summary" + content: + application/json: + schema: + $ref: "#/components/schemas/UsageSummaryResponse" + "500": + $ref: "#/components/responses/InternalError" + + /analytics/top-countries: + get: + tags: + - "analytics" + operationId: "getTopCountries" + summary: "Get top countries by unique users" + description: "Top countries by unique users (fingerprints), not visit-day counts." + parameters: + - name: limit + in: query + description: "Maximum number of countries to return" + schema: + type: integer + minimum: 1 + maximum: 50 + default: 10 + responses: + "200": + description: "Top countries" + content: + application/json: + schema: + $ref: "#/components/schemas/TopCountriesResponse" + "500": + $ref: "#/components/responses/InternalError" + + /analytics/capabilities: + get: + tags: + - "analytics" + operationId: "getUsageCapabilities" + summary: "Get product-capability usage" + description: "Product-capability usage from UsageRollup (unique users who touched each bucket). Empty items if the daily rollup has not run yet." + responses: + "200": + description: "Capability usage" + content: + application/json: + schema: + $ref: "#/components/schemas/UsageCapabilitiesResponse" + "500": + $ref: "#/components/responses/InternalError" + + /analytics/top-entities: + get: + tags: + - "analytics" + operationId: "getTopEntities" + summary: "Get top opened entities" + description: "Top-10 opened assemblies, annotations, and taxons by unique users." + responses: + "200": + description: "Top entities" + content: + application/json: + schema: + $ref: "#/components/schemas/TopEntitiesResponse" + "500": + $ref: "#/components/responses/InternalError" + components: parameters: filter: @@ -1621,10 +2043,10 @@ components: selected_fields: name: "selected_fields" in: "query" - description: "Comma-separated list of extended TSV column keys to append after the default export columns. Only applies to /annotations/report. Omit to keep the production default column set." + description: "Comma-separated list of extended TSV column keys to append after the default export columns. Only applies to /annotations/report. Omit to keep the production default column set. Assembly-derived columns (assembly_refseq_category, assembly_download_url, assembly_gc_percent) are resolved via a join on assembly_accession; assembly_download_url is left empty when the URL is still a localhost placeholder." schema: type: "string" - example: "release_date,taxon_lineage,busco_complete" + example: "release_date,taxon_lineage,busco_complete,assembly_gc_percent" rank: name: "rank" in: "query" @@ -1726,7 +2148,7 @@ components: type: string selected_fields: type: string - description: "Comma-separated extended TSV column keys to append after default columns (report export only)." + description: "Comma-separated extended TSV column keys to append after default columns (report export only). Includes annotation extended fields and assembly-derived columns (assembly_refseq_category, assembly_download_url, assembly_gc_percent)." AssemblyQueryParams: type: object @@ -2781,3 +3203,282 @@ components: - metric - values - missing + + AnnotationsAggregatesByTaxonResponse: + type: object + description: "Annotation aggregates grouped by taxon at a given rank. fields lists the fixed column order used by rows." + properties: + fields: + type: array + items: + type: string + description: "Fixed column names: taxid, taxon_name, annotations_count, avg_coding_genes_count, avg_non_coding_genes_count, avg_pseudogenes_count" + rows: + type: array + items: + type: array + items: {} + description: "One row per taxon, values aligned with fields" + required: + - fields + - rows + + BuscoStatsSummaryResponse: + type: object + properties: + total_annotations: + type: integer + description: "Total number of annotations in queryset" + summary: + type: object + additionalProperties: + $ref: "#/components/schemas/BuscoMetricSummary" + description: "Per-metric stats keyed by complete, single_copy, duplicated, fragmented, missing" + metrics: + type: array + items: + type: string + description: "List of available BUSCO metrics" + required: + - total_annotations + - summary + - metrics + + BuscoMetricSummary: + type: object + properties: + annotations_count: + type: integer + description: "Number of annotations with BUSCO data" + missing_annotations_count: + type: integer + description: "Number of annotations missing BUSCO data" + mean: + type: number + nullable: true + description: "Mean value for this metric" + required: + - annotations_count + - missing_annotations_count + + BuscoMetricValuesResponse: + type: object + properties: + metric: + type: string + description: "The BUSCO metric name" + values: + type: array + items: + type: number + description: "List of values (ordered by annotation_id)" + annotation_ids: + type: array + items: + type: string + description: "List of annotation_ids (only if include_annotations=true, ordered to match values)" + required: + - metric + - values + + UploadGffResponse: + type: object + properties: + task_id: + type: string + description: "Celery task ID for the upload job" + remaining_quota: + type: integer + description: "Remaining uploads allowed for this client in the current 24h window" + required: + - task_id + - remaining_quota + + UploadJobStatusResponse: + type: object + properties: + task_id: + type: string + state: + type: string + description: "Celery task state (PENDING, PROGRESS, SUCCESS, FAILURE, etc.)" + meta: + type: object + additionalProperties: true + description: "Progress metadata when state is PROGRESS" + result: + description: "Job result when state is SUCCESS" + error: + type: string + description: "Error message when state is FAILURE" + required: + - task_id + - state + + UploadRateLimitResponse: + type: object + properties: + used: + type: integer + description: "Uploads used in the last 24 hours" + remaining: + type: integer + description: "Remaining uploads in the current 24h window" + required: + - used + - remaining + + FlattenedTreeResponse: + type: object + description: "Flattened taxonomy tree as fields + rows. Column order is fixed." + properties: + fields: + type: array + items: + type: string + description: "Fixed column names: taxid, parent_taxid, scientific_name, annotations_count, assemblies_count, organisms_count, rank, coding_mean_count, non_coding_mean_count, pseudogene_mean_count, mRNA_mean_count, lncRNA_mean_count, tRNA_mean_count, miRNA_mean_count, busco_single_copy_mean, busco_duplicated_mean, busco_fragmented_mean, busco_missing_mean" + rows: + type: array + items: + type: array + items: {} + description: "One row per taxon, values aligned with fields" + required: + - fields + - rows + + TopVisitor: + type: object + properties: + country: + type: string + visits_count: + type: integer + required: + - country + - visits_count + + TopVisitorsResponse: + type: array + items: + $ref: "#/components/schemas/TopVisitor" + + UsageSummaryResponse: + type: object + properties: + unique_users: + type: integer + active_30d: + type: integer + countries: + type: integer + returning_pct: + type: number + as_of: + type: string + format: date-time + required: + - unique_users + - active_30d + - countries + - returning_pct + - as_of + + TopCountry: + type: object + properties: + country: + type: string + unique_users: + type: integer + required: + - country + - unique_users + + TopCountriesResponse: + type: array + items: + $ref: "#/components/schemas/TopCountry" + + UsageCapabilityItem: + type: object + properties: + id: + type: string + label: + type: string + unique_users: + type: integer + request_count: + type: integer + required: + - id + - label + - unique_users + + UsageCapabilitiesResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/UsageCapabilityItem" + as_of: + type: string + format: date-time + nullable: true + required: + - items + + TopEntityRow: + type: object + properties: + id: + type: string + unique_users: + type: integer + label: + type: string + nullable: true + organism_name: + type: string + nullable: true + assembly_accession: + type: string + nullable: true + provider: + type: string + nullable: true + database: + type: string + nullable: true + rank: + type: string + nullable: true + required: + - id + - unique_users + + TopEntitiesResponse: + type: object + properties: + top_assemblies: + type: array + items: + $ref: "#/components/schemas/TopEntityRow" + top_annotations: + type: array + items: + $ref: "#/components/schemas/TopEntityRow" + top_taxons: + type: array + items: + $ref: "#/components/schemas/TopEntityRow" + as_of: + type: string + format: date-time + nullable: true + required: + - top_assemblies + - top_annotations + - top_taxons diff --git a/front/components/annotations/download-tsv-dialog.tsx b/front/components/annotations/download-tsv-dialog.tsx index 36fb3b7..6b0bb47 100644 --- a/front/components/annotations/download-tsv-dialog.tsx +++ b/front/components/annotations/download-tsv-dialog.tsx @@ -19,6 +19,7 @@ import { } from "@/lib/api/annotations" import { buildSelectedFieldsParam, + getAssemblyTsvFields, getDefaultTsvFields, getExtendedTsvFields, } from "@/lib/annotations-tsv-fields" @@ -41,6 +42,7 @@ export function DownloadTsvDialog({ const defaultFields = useMemo(() => getDefaultTsvFields(), []) const extendedFields = useMemo(() => getExtendedTsvFields(), []) + const assemblyFields = useMemo(() => getAssemblyTsvFields(), []) const additionalCount = checkedExtended.size const totalColumnCount = defaultFields.length + additionalCount @@ -151,7 +153,39 @@ export function DownloadTsvDialog({ - +
+
Assembly fields
+

+ Resolved from the parent genome assembly record (joined on assembly accession), + so you can get both the GFF and the FASTA download link in one TSV. +

+
+ {assemblyFields.map((field) => { + const checkboxId = `tsv-field-${field.key}` + return ( +
+ + toggleExtendedField(field.key, value === true) + } + disabled={loading} + /> + +
+ ) + })} +
+
Summary
@@ -186,6 +220,11 @@ export function DownloadTsvDialog({ {" "} to this path. +
  • + assembly_download_url: direct + link to the genome assembly FASTA file, resolved from the assembly record. Left + empty when the URL is not yet resolved. +
  • diff --git a/front/public/annotrieve-api-specs.yaml b/front/public/annotrieve-api-specs.yaml index 487e319..4eba63c 100644 --- a/front/public/annotrieve-api-specs.yaml +++ b/front/public/annotrieve-api-specs.yaml @@ -22,6 +22,8 @@ tags: description: "Taxonomy information endpoints" - name: "bioprojects" description: "BioProject information endpoints" + - name: "analytics" + description: "Public usage analytics endpoints" paths: /annotations: @@ -96,7 +98,7 @@ paths: - "annotations" operationId: "getAnnotationsReport" summary: "Download annotation metadata as TSV" - description: "Streams a TSV file with annotation metadata columns. By default exports annotation_id, assembly_accession, assembly_name, organism_name, taxid, database, provider, source_url, bgzip_path, and csi_path. Use selected_fields to append additional extended columns." + description: "Streams a TSV file with annotation metadata columns. By default exports annotation_id, assembly_accession, assembly_name, organism_name, taxid, database, provider, source_url, bgzip_path, and csi_path. Use selected_fields to append additional extended columns, including assembly-derived fields (assembly_refseq_category, assembly_download_url, assembly_gc_percent) resolved via a join on assembly_accession." parameters: - $ref: "#/components/parameters/filter" - $ref: "#/components/parameters/limit" @@ -138,7 +140,7 @@ paths: - "annotations" operationId: "postAnnotationsReport" summary: "Download annotation metadata via POST" - description: "Same as GET /annotations/report, but accepts filters in the request body. Omit selected_fields to preserve the default production column set; provide it to append extended columns after the defaults." + description: "Same as GET /annotations/report, but accepts filters in the request body. Omit selected_fields to preserve the default production column set; provide it to append extended columns after the defaults, including assembly-derived fields (assembly_refseq_category, assembly_download_url, assembly_gc_percent) resolved via a join on assembly_accession." requestBody: required: true content: @@ -261,6 +263,33 @@ paths: "500": $ref: "#/components/responses/InternalError" + /annotations/aggregates/taxons: + get: + tags: + - "annotations" + operationId: "getAnnotationsAggregatesByTaxonRank" + summary: "Get annotation aggregates grouped by taxon rank" + description: "Returns one record per taxon at the given rank with average coding/non-coding/pseudogene gene counts and annotation count." + parameters: + - name: rank + in: query + required: true + description: "Taxonomic rank to aggregate by." + schema: + type: string + enum: ["domain", "kingdom", "phylum", "class", "order", "family", "genus"] + responses: + "200": + description: "Aggregates by taxon" + content: + application/json: + schema: + $ref: "#/components/schemas/AnnotationsAggregatesByTaxonResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + /annotations/gene-stats: get: tags: @@ -737,6 +766,245 @@ paths: "500": $ref: "#/components/responses/InternalError" + /annotations/busco-stats: + get: + tags: + - "annotations" + operationId: "getBuscoStats" + summary: "Get BUSCO stats summary" + description: "Returns BUSCO stats summary with aggregated statistics for metrics only (complete, single_copy, duplicated, fragmented, missing). No categories." + parameters: + - $ref: "#/components/parameters/filter" + - $ref: "#/components/parameters/taxids" + - $ref: "#/components/parameters/assembly_accessions" + - $ref: "#/components/parameters/bioproject_accessions" + - $ref: "#/components/parameters/db_sources" + - $ref: "#/components/parameters/feature_sources" + - $ref: "#/components/parameters/biotypes" + - $ref: "#/components/parameters/feature_types" + - $ref: "#/components/parameters/pipelines" + - $ref: "#/components/parameters/providers" + - $ref: "#/components/parameters/md5_checksums" + - $ref: "#/components/parameters/has_stats" + - $ref: "#/components/parameters/refseq_categories" + - $ref: "#/components/parameters/assembly_levels" + - $ref: "#/components/parameters/assembly_statuses" + - $ref: "#/components/parameters/assembly_types" + - $ref: "#/components/parameters/release_date_from" + - $ref: "#/components/parameters/release_date_to" + responses: + "200": + description: "BUSCO stats summary" + content: + application/json: + schema: + $ref: "#/components/schemas/BuscoStatsSummaryResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + post: + tags: + - "annotations" + operationId: "postBuscoStats" + summary: "Get BUSCO stats summary via POST" + description: "Same as GET /annotations/busco-stats, but accepts filters in the request body." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AnnotationQueryParams" + responses: + "200": + description: "BUSCO stats summary" + content: + application/json: + schema: + $ref: "#/components/schemas/BuscoStatsSummaryResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + + /annotations/busco-stats/{metric}: + get: + tags: + - "annotations" + operationId: "getBuscoMetricValues" + summary: "Get raw values for a specific BUSCO metric" + description: "Returns raw values for a specific BUSCO metric (for plotting histograms)." + parameters: + - name: metric + in: path + required: true + description: "BUSCO metric name (complete, single_copy, duplicated, fragmented, or missing)" + schema: + type: string + enum: ["complete", "single_copy", "duplicated", "fragmented", "missing"] + - name: include_annotations + in: query + description: "If true, include annotation_ids list in response" + schema: + type: boolean + default: false + - $ref: "#/components/parameters/filter" + - $ref: "#/components/parameters/taxids" + - $ref: "#/components/parameters/assembly_accessions" + - $ref: "#/components/parameters/bioproject_accessions" + - $ref: "#/components/parameters/db_sources" + - $ref: "#/components/parameters/feature_sources" + - $ref: "#/components/parameters/biotypes" + - $ref: "#/components/parameters/feature_types" + - $ref: "#/components/parameters/pipelines" + - $ref: "#/components/parameters/providers" + - $ref: "#/components/parameters/md5_checksums" + - $ref: "#/components/parameters/has_stats" + - $ref: "#/components/parameters/refseq_categories" + - $ref: "#/components/parameters/assembly_levels" + - $ref: "#/components/parameters/assembly_statuses" + - $ref: "#/components/parameters/assembly_types" + - $ref: "#/components/parameters/release_date_from" + - $ref: "#/components/parameters/release_date_to" + responses: + "200": + description: "Metric values" + content: + application/json: + schema: + $ref: "#/components/schemas/BuscoMetricValuesResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + post: + tags: + - "annotations" + operationId: "postBuscoMetricValues" + summary: "Get BUSCO metric values via POST" + description: "Same as GET /annotations/busco-stats/{metric}, but accepts filters in the request body." + parameters: + - name: metric + in: path + required: true + description: "BUSCO metric name (complete, single_copy, duplicated, fragmented, or missing)" + schema: + type: string + enum: ["complete", "single_copy", "duplicated", "fragmented", "missing"] + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/AnnotationQueryParams" + - type: object + properties: + include_annotations: + type: boolean + description: "If true, include annotation_ids list in response" + default: false + responses: + "200": + description: "Metric values" + content: + application/json: + schema: + $ref: "#/components/schemas/BuscoMetricValuesResponse" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + + /annotations/upload-gff: + post: + tags: + - "annotations" + operationId: "uploadCustomGff" + summary: "Upload a custom GFF/GFF3 file" + description: "Upload a custom GFF/GFF3 file and enqueue a background job to compute feature summary and statistics. Subject to a per-client daily rate limit." + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + - custom_name + properties: + file: + type: string + format: binary + description: "GFF/GFF3 file (.gff, .gff3, .gff.gz, or .gff3.gz)" + custom_name: + type: string + description: "Display name for the uploaded annotation" + responses: + "200": + description: "Upload accepted and job enqueued" + content: + application/json: + schema: + $ref: "#/components/schemas/UploadGffResponse" + "400": + $ref: "#/components/responses/BadRequest" + "413": + description: "Uploaded file is too large" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "429": + description: "Daily upload limit reached" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + $ref: "#/components/responses/InternalError" + + /annotations/upload-gff/jobs/{task_id}: + get: + tags: + - "annotations" + operationId: "getUploadGffJobStatus" + summary: "Get custom GFF upload job status" + description: "Returns the status of a custom GFF upload background job." + parameters: + - name: task_id + in: path + required: true + description: "Celery task ID returned by POST /annotations/upload-gff" + schema: + type: string + responses: + "200": + description: "Job status" + content: + application/json: + schema: + $ref: "#/components/schemas/UploadJobStatusResponse" + "500": + $ref: "#/components/responses/InternalError" + + /annotations/upload-gff/rate-limit: + get: + tags: + - "annotations" + operationId: "getUploadGffRateLimit" + summary: "Get custom GFF upload rate-limit status" + description: "Returns how many uploads were used in the last 24 hours and remaining quota for the calling client." + responses: + "200": + description: "Rate-limit status" + content: + application/json: + schema: + $ref: "#/components/schemas/UploadRateLimitResponse" + "500": + $ref: "#/components/responses/InternalError" + /annotations/{md5_checksum}: get: tags: @@ -1296,6 +1564,39 @@ paths: "500": $ref: "#/components/responses/InternalError" + /taxons/flattened-tree: + get: + tags: + - "taxons" + operationId: "getFlattenedTree" + summary: "Get flattened taxonomy tree" + description: "Returns a flattened taxonomy tree. When a prebuilt export exists, responds with a 307 redirect to the static file under /annotrieve/files/taxonomy/. Otherwise returns an on-the-fly aggregation as JSON ({fields, rows}) or a TSV stream depending on format." + parameters: + - name: format + in: query + description: "Response format: json (default) or tsv." + schema: + type: string + enum: ["json", "tsv"] + default: "json" + responses: + "200": + description: "Flattened taxonomy tree" + content: + application/json: + schema: + $ref: "#/components/schemas/FlattenedTreeResponse" + text/tab-separated-values: + schema: + type: string + description: "TSV payload streamed line by line" + "307": + description: "Redirect to prebuilt static flattened-tree file when available" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + /taxons/{taxid}: get: tags: @@ -1454,6 +1755,127 @@ paths: "500": $ref: "#/components/responses/InternalError" + /analytics/frequencies/country: + get: + tags: + - "analytics" + operationId: "getCountryFrequencies" + summary: "Get unique users by country" + description: "Returns frequency counts of unique users by country. Each user is identified by an anonymous fingerprint (HMAC of IP)." + responses: + "200": + description: "Country frequency counts" + content: + application/json: + schema: + $ref: "#/components/schemas/FrequencyCounts" + "500": + $ref: "#/components/responses/InternalError" + + /analytics/top-visitors: + get: + tags: + - "analytics" + operationId: "getTopVisitors" + summary: "Get top anonymous visitors by visit days" + deprecated: true + description: "Top anonymous visitors by distinct visit days. Prefer /analytics/top-countries for public usage UI. Never returns fingerprints or IPs." + parameters: + - name: limit + in: query + description: "Maximum number of visitors to return (capped at 5)" + schema: + type: integer + minimum: 1 + maximum: 5 + default: 5 + responses: + "200": + description: "Top visitors" + content: + application/json: + schema: + $ref: "#/components/schemas/TopVisitorsResponse" + "500": + $ref: "#/components/responses/InternalError" + + /analytics/summary: + get: + tags: + - "analytics" + operationId: "getUsageSummary" + summary: "Get public usage summary metrics" + description: "Public usage hero metrics from UserAnalytics (API-activity users). Never returns fingerprints or IPs." + responses: + "200": + description: "Usage summary" + content: + application/json: + schema: + $ref: "#/components/schemas/UsageSummaryResponse" + "500": + $ref: "#/components/responses/InternalError" + + /analytics/top-countries: + get: + tags: + - "analytics" + operationId: "getTopCountries" + summary: "Get top countries by unique users" + description: "Top countries by unique users (fingerprints), not visit-day counts." + parameters: + - name: limit + in: query + description: "Maximum number of countries to return" + schema: + type: integer + minimum: 1 + maximum: 50 + default: 10 + responses: + "200": + description: "Top countries" + content: + application/json: + schema: + $ref: "#/components/schemas/TopCountriesResponse" + "500": + $ref: "#/components/responses/InternalError" + + /analytics/capabilities: + get: + tags: + - "analytics" + operationId: "getUsageCapabilities" + summary: "Get product-capability usage" + description: "Product-capability usage from UsageRollup (unique users who touched each bucket). Empty items if the daily rollup has not run yet." + responses: + "200": + description: "Capability usage" + content: + application/json: + schema: + $ref: "#/components/schemas/UsageCapabilitiesResponse" + "500": + $ref: "#/components/responses/InternalError" + + /analytics/top-entities: + get: + tags: + - "analytics" + operationId: "getTopEntities" + summary: "Get top opened entities" + description: "Top-10 opened assemblies, annotations, and taxons by unique users." + responses: + "200": + description: "Top entities" + content: + application/json: + schema: + $ref: "#/components/schemas/TopEntitiesResponse" + "500": + $ref: "#/components/responses/InternalError" + components: parameters: filter: @@ -1621,10 +2043,10 @@ components: selected_fields: name: "selected_fields" in: "query" - description: "Comma-separated list of extended TSV column keys to append after the default export columns. Only applies to /annotations/report. Omit to keep the production default column set." + description: "Comma-separated list of extended TSV column keys to append after the default export columns. Only applies to /annotations/report. Omit to keep the production default column set. Assembly-derived columns (assembly_refseq_category, assembly_download_url, assembly_gc_percent) are resolved via a join on assembly_accession; assembly_download_url is left empty when the URL is still a localhost placeholder." schema: type: "string" - example: "release_date,taxon_lineage,busco_complete" + example: "release_date,taxon_lineage,busco_complete,assembly_gc_percent" rank: name: "rank" in: "query" @@ -1726,7 +2148,7 @@ components: type: string selected_fields: type: string - description: "Comma-separated extended TSV column keys to append after default columns (report export only)." + description: "Comma-separated extended TSV column keys to append after default columns (report export only). Includes annotation extended fields and assembly-derived columns (assembly_refseq_category, assembly_download_url, assembly_gc_percent)." AssemblyQueryParams: type: object @@ -2781,3 +3203,282 @@ components: - metric - values - missing + + AnnotationsAggregatesByTaxonResponse: + type: object + description: "Annotation aggregates grouped by taxon at a given rank. fields lists the fixed column order used by rows." + properties: + fields: + type: array + items: + type: string + description: "Fixed column names: taxid, taxon_name, annotations_count, avg_coding_genes_count, avg_non_coding_genes_count, avg_pseudogenes_count" + rows: + type: array + items: + type: array + items: {} + description: "One row per taxon, values aligned with fields" + required: + - fields + - rows + + BuscoStatsSummaryResponse: + type: object + properties: + total_annotations: + type: integer + description: "Total number of annotations in queryset" + summary: + type: object + additionalProperties: + $ref: "#/components/schemas/BuscoMetricSummary" + description: "Per-metric stats keyed by complete, single_copy, duplicated, fragmented, missing" + metrics: + type: array + items: + type: string + description: "List of available BUSCO metrics" + required: + - total_annotations + - summary + - metrics + + BuscoMetricSummary: + type: object + properties: + annotations_count: + type: integer + description: "Number of annotations with BUSCO data" + missing_annotations_count: + type: integer + description: "Number of annotations missing BUSCO data" + mean: + type: number + nullable: true + description: "Mean value for this metric" + required: + - annotations_count + - missing_annotations_count + + BuscoMetricValuesResponse: + type: object + properties: + metric: + type: string + description: "The BUSCO metric name" + values: + type: array + items: + type: number + description: "List of values (ordered by annotation_id)" + annotation_ids: + type: array + items: + type: string + description: "List of annotation_ids (only if include_annotations=true, ordered to match values)" + required: + - metric + - values + + UploadGffResponse: + type: object + properties: + task_id: + type: string + description: "Celery task ID for the upload job" + remaining_quota: + type: integer + description: "Remaining uploads allowed for this client in the current 24h window" + required: + - task_id + - remaining_quota + + UploadJobStatusResponse: + type: object + properties: + task_id: + type: string + state: + type: string + description: "Celery task state (PENDING, PROGRESS, SUCCESS, FAILURE, etc.)" + meta: + type: object + additionalProperties: true + description: "Progress metadata when state is PROGRESS" + result: + description: "Job result when state is SUCCESS" + error: + type: string + description: "Error message when state is FAILURE" + required: + - task_id + - state + + UploadRateLimitResponse: + type: object + properties: + used: + type: integer + description: "Uploads used in the last 24 hours" + remaining: + type: integer + description: "Remaining uploads in the current 24h window" + required: + - used + - remaining + + FlattenedTreeResponse: + type: object + description: "Flattened taxonomy tree as fields + rows. Column order is fixed." + properties: + fields: + type: array + items: + type: string + description: "Fixed column names: taxid, parent_taxid, scientific_name, annotations_count, assemblies_count, organisms_count, rank, coding_mean_count, non_coding_mean_count, pseudogene_mean_count, mRNA_mean_count, lncRNA_mean_count, tRNA_mean_count, miRNA_mean_count, busco_single_copy_mean, busco_duplicated_mean, busco_fragmented_mean, busco_missing_mean" + rows: + type: array + items: + type: array + items: {} + description: "One row per taxon, values aligned with fields" + required: + - fields + - rows + + TopVisitor: + type: object + properties: + country: + type: string + visits_count: + type: integer + required: + - country + - visits_count + + TopVisitorsResponse: + type: array + items: + $ref: "#/components/schemas/TopVisitor" + + UsageSummaryResponse: + type: object + properties: + unique_users: + type: integer + active_30d: + type: integer + countries: + type: integer + returning_pct: + type: number + as_of: + type: string + format: date-time + required: + - unique_users + - active_30d + - countries + - returning_pct + - as_of + + TopCountry: + type: object + properties: + country: + type: string + unique_users: + type: integer + required: + - country + - unique_users + + TopCountriesResponse: + type: array + items: + $ref: "#/components/schemas/TopCountry" + + UsageCapabilityItem: + type: object + properties: + id: + type: string + label: + type: string + unique_users: + type: integer + request_count: + type: integer + required: + - id + - label + - unique_users + + UsageCapabilitiesResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/UsageCapabilityItem" + as_of: + type: string + format: date-time + nullable: true + required: + - items + + TopEntityRow: + type: object + properties: + id: + type: string + unique_users: + type: integer + label: + type: string + nullable: true + organism_name: + type: string + nullable: true + assembly_accession: + type: string + nullable: true + provider: + type: string + nullable: true + database: + type: string + nullable: true + rank: + type: string + nullable: true + required: + - id + - unique_users + + TopEntitiesResponse: + type: object + properties: + top_assemblies: + type: array + items: + $ref: "#/components/schemas/TopEntityRow" + top_annotations: + type: array + items: + $ref: "#/components/schemas/TopEntityRow" + top_taxons: + type: array + items: + $ref: "#/components/schemas/TopEntityRow" + as_of: + type: string + format: date-time + nullable: true + required: + - top_assemblies + - top_annotations + - top_taxons diff --git a/server/helpers/tsv_fields.py b/server/helpers/tsv_fields.py index c854e06..cb72ecf 100644 --- a/server/helpers/tsv_fields.py +++ b/server/helpers/tsv_fields.py @@ -4,27 +4,27 @@ from fastapi import HTTPException +from db.models import GenomeAssembly from helpers import constants as constants_helper from helpers import parameters as params_helper -def resolve_tsv_field_map(selected_fields: str | list[str] | None) -> dict[str, str]: +def _validate_selected_fields(selected_fields: str | list[str] | None) -> list[str]: """ - Resolve the TSV column map for export. - - When selected_fields is omitted, returns the frozen production default map. - When present, appends validated extended columns after the defaults. + Normalize selected_fields and validate against the combined set of allowed + extended columns (GenomeAnnotation-side + GenomeAssembly-side). Returns the + normalized, de-duplication-preserving list of requested keys (possibly empty). """ - if selected_fields is None: - return dict(constants_helper.FIELD_TSV_MAP) - requested = params_helper.normalize_to_list(selected_fields) if not requested: - return dict(constants_helper.FIELD_TSV_MAP) + return [] - invalid = [key for key in requested if key not in constants_helper.FIELD_TSV_EXTENDED_MAP] + allowed_extended = set(constants_helper.FIELD_TSV_EXTENDED_MAP) | set( + constants_helper.FIELD_TSV_ASSEMBLY_MAP + ) + invalid = [key for key in requested if key not in allowed_extended] if invalid: - allowed = ", ".join(constants_helper.FIELD_TSV_EXTENDED_MAP.keys()) + allowed = ", ".join(sorted(allowed_extended)) raise HTTPException( status_code=400, detail=f"Invalid selected_fields: {', '.join(invalid)}. Allowed extended fields: {allowed}", @@ -37,6 +37,27 @@ def resolve_tsv_field_map(selected_fields: str | list[str] | None) -> dict[str, detail=f"selected_fields must only contain extended columns. Redundant default fields: {', '.join(redundant)}", ) + return requested + + +def resolve_tsv_field_map(selected_fields: str | list[str] | None) -> dict[str, str]: + """ + Resolve the TSV column map for export (GenomeAnnotation-side columns only). + + When selected_fields is omitted, returns the frozen production default map. + When present, appends validated extended columns after the defaults. Keys + belonging to FIELD_TSV_ASSEMBLY_MAP are valid tokens but are intentionally + left out of this map — see resolve_assembly_tsv_field_map, since those + columns resolve against a different collection (GenomeAssembly) and require + a join rather than a direct projection. + """ + if selected_fields is None: + return dict(constants_helper.FIELD_TSV_MAP) + + requested = _validate_selected_fields(selected_fields) + if not requested: + return dict(constants_helper.FIELD_TSV_MAP) + field_map = dict(constants_helper.FIELD_TSV_MAP) requested_set = set(requested) for key in constants_helper.FIELD_TSV_EXTENDED_MAP: @@ -45,6 +66,28 @@ def resolve_tsv_field_map(selected_fields: str | list[str] | None) -> dict[str, return field_map +def resolve_assembly_tsv_field_map(selected_fields: str | list[str] | None) -> dict[str, str]: + """ + Resolve the subset of requested columns that must be joined from the parent + GenomeAssembly model (see FIELD_TSV_ASSEMBLY_MAP). Returns an empty dict when + selected_fields is omitted/empty, or when none of the requested keys are + assembly-derived. Column order follows FIELD_TSV_ASSEMBLY_MAP declaration order. + """ + if selected_fields is None: + return {} + + requested = _validate_selected_fields(selected_fields) + if not requested: + return {} + + requested_set = set(requested) + return { + key: path + for key, path in constants_helper.FIELD_TSV_ASSEMBLY_MAP.items() + if key in requested_set + } + + def dig_mongo_value(doc: dict, mongo_path: str) -> Any: """ Null-safe nested lookup for mongoengine-style paths (double-underscore). @@ -80,6 +123,54 @@ def iter_tsv_rows( yield tuple(dig_mongo_value(doc, path) for path in paths) +def resolve_assembly_rows( + batch_rows: list[tuple], + accession_index: int, + assembly_field_map: dict[str, str], +) -> list[tuple]: + """ + Resolve assembly-derived columns for a batch of annotation rows via a single + batched join on assembly_accession, keeping query volume proportional to the + number of batches (not the number of rows) and to the number of *distinct* + assemblies referenced in the batch (typically much smaller than the batch size, + since many annotations share the same assembly). + + Returns a list of value-tuples (same order as assembly_field_map, aligned with + batch_rows) meant to be concatenated onto each row. + """ + if not assembly_field_map: + return [() for _ in batch_rows] + + assembly_paths = list(assembly_field_map.values()) + accessions = {row[accession_index] for row in batch_rows if row[accession_index]} + + assembly_by_accession: dict[str, dict] = {} + if accessions: + cursor = GenomeAssembly.objects(assembly_accession__in=list(accessions)).only( + "assembly_accession", *assembly_paths + ) + for doc in cursor.as_pymongo(): + assembly_by_accession[doc.get("assembly_accession")] = doc + + def resolve_value(key: str, path: str, doc: Optional[dict]): + value = dig_mongo_value(doc, path) if doc else None + if ( + key == "assembly_download_url" + and isinstance(value, str) + and value.startswith(constants_helper.PLACEHOLDER_DOWNLOAD_URL_PREFIX) + ): + return None + return value + + return [ + tuple( + resolve_value(key, path, assembly_by_accession.get(row[accession_index])) + for key, path in assembly_field_map.items() + ) + for row in batch_rows + ] + + def format_tsv_cell(value, *, extended: bool = False) -> str: if value is None: return "" diff --git a/server/services/annotations_service.py b/server/services/annotations_service.py index 6106ad9..28394ec 100644 --- a/server/services/annotations_service.py +++ b/server/services/annotations_service.py @@ -46,9 +46,13 @@ def get_annotations(args: dict, field: str = None, response_type: str = 'metadat def stream_annotation_tsv(annotations, selected_fields=None): field_map = tsv_fields_helper.resolve_tsv_field_map(selected_fields) + assembly_field_map = tsv_fields_helper.resolve_assembly_tsv_field_map(selected_fields) mongo_paths = list(field_map.values()) - column_keys = list(field_map.keys()) - extended_keys = set(constants_helper.FIELD_TSV_EXTENDED_MAP.keys()) + column_keys = list(field_map.keys()) + list(assembly_field_map.keys()) + accession_index = mongo_paths.index("assembly_accession") + extended_keys = set(constants_helper.FIELD_TSV_EXTENDED_MAP.keys()) | set( + constants_helper.FIELD_TSV_ASSEMBLY_MAP.keys() + ) use_extended_formatting = selected_fields is not None and bool( params_helper.normalize_to_list(selected_fields) ) @@ -69,6 +73,13 @@ def row_iterator(): annotations, mongo_paths, batch_size=TSV_BUFFER_SIZE ) while batch := list(itertools.islice(cursor, TSV_BUFFER_SIZE)): + if assembly_field_map: + # One batched join query per buffer instead of per row, keyed on + # the distinct assembly_accessions already present in the batch. + assembly_values = tsv_fields_helper.resolve_assembly_rows( + batch, accession_index, assembly_field_map + ) + batch = [row + extra for row, extra in zip(batch, assembly_values)] yield "".join(format_row(row) for row in batch).encode() return StreamingResponse( row_iterator(), From 8bb61dc0b580be901da2cfea4d3e73321a7c333e Mon Sep 17 00:00:00 2001 From: Emilio Righi Date: Wed, 12 Aug 2026 09:53:05 +0200 Subject: [PATCH 2/5] add tests --- .github/workflows/build-all.yml | 3 +- .github/workflows/front-test.yml | 29 + .github/workflows/main-pipeline.yml | 18 + .github/workflows/server-test.yml | 73 +++ .gitignore | 4 + front/lib/analytics-params.ts | 48 ++ front/lib/annotation-display.test.ts | 137 +++++ front/lib/annotation-display.ts | 20 + front/lib/annotation-metric-values.test.ts | 108 ++++ front/lib/annotation-metric-values.ts | 19 + front/lib/annotations-tsv-fields.test.ts | 35 ++ front/lib/annotations-tsv-fields.ts | 19 +- front/lib/api/analytics.test.ts | 72 +++ front/lib/api/annotations.test.ts | 279 ++++++++++ front/lib/api/assemblies.test.ts | 72 +++ front/lib/api/base.test.ts | 110 ++++ front/lib/api/base.ts | 6 +- front/lib/api/bioprojects.test.ts | 35 ++ front/lib/api/files.test.ts | 81 +++ front/lib/api/organisms.test.ts | 32 ++ front/lib/api/taxons.test.ts | 116 ++++ front/lib/api/taxons.ts | 2 +- front/lib/custom-annotations-jsonl.test.ts | 87 +++ front/lib/custom-upload-session.test.ts | 123 +++++ front/lib/hooks/use-analytics-data.ts | 34 +- .../hooks/use-annotation-overview-url-sync.ts | 21 +- .../lib/hooks/use-favorites-reference-data.ts | 22 +- .../hooks/use-merged-favorite-annotations.ts | 8 +- front/lib/test/mock-fetch.test.ts | 38 ++ front/lib/test/mock-fetch.ts | 120 ++++ front/lib/utils.test.ts | 91 +++ front/package-lock.json | 519 ++++++++++++++++++ front/package.json | 5 +- server/helpers/constants.py | 25 + server/jobs/services/assembly.py | 2 +- server/pytest.ini | 6 + server/requirements-dev.txt | 5 + server/tests/conftest.py | 61 ++ server/tests/integration/.gitkeep | 0 server/tests/integration/__init__.py | 0 server/tests/integration/conftest.py | 158 ++++++ server/tests/integration/factories.py | 285 ++++++++++ server/tests/integration/test_analytics.py | 32 ++ .../integration/test_annotation_detail.py | 27 + .../integration/test_annotation_stats.py | 34 ++ .../integration/test_annotations_list.py | 43 ++ .../integration/test_annotations_report.py | 41 ++ server/tests/integration/test_assemblies.py | 52 ++ server/tests/integration/test_jobs_auth.py | 28 + .../integration/test_organisms_bioprojects.py | 31 ++ .../integration/test_taxonomy_flattened.py | 29 + server/tests/integration/test_upload_gff.py | 89 +++ server/tests/test_tsv_fields.py | 120 ---- server/tests/unit/__init__.py | 0 server/tests/unit/fakes.py | 91 +++ server/tests/unit/helpers/__init__.py | 0 server/tests/unit/helpers/test_busco_stats.py | 73 +++ .../tests/unit/helpers/test_feature_stats.py | 132 +++++ .../helpers/test_flattened_taxonomy_export.py | 81 +++ .../{ => unit/helpers}/test_parameters.py | 4 + server/tests/unit/helpers/test_tsv_fields.py | 259 +++++++++ server/tests/unit/jobs/__init__.py | 0 .../unit/jobs/test_annotation_job_helpers.py | 52 ++ .../tests/unit/jobs/test_assemblies_task.py | 26 + .../tests/unit/jobs/test_assembly_helpers.py | 33 ++ .../tests/unit/jobs/test_assembly_summary.py | 52 ++ .../tests/unit/jobs/test_contigs_helpers.py | 20 + .../unit/jobs/test_feature_stats_lines.py | 28 + .../tests/unit/jobs/test_feature_summary.py | 33 ++ .../unit/jobs/test_import_annotations_task.py | 137 +++++ .../tests/unit/jobs/test_migration_tasks.py | 120 ++++ server/tests/unit/jobs/test_stats_helpers.py | 44 ++ .../unit/jobs/test_taxonomy_export_task.py | 20 + .../unit/jobs/test_taxonomy_job_helpers.py | 35 ++ .../tests/unit/jobs/test_track_users_task.py | 84 +++ server/tests/unit/jobs/test_updates_tasks.py | 81 +++ .../tests/unit/jobs/test_upload_gff_task.py | 112 ++++ server/tests/unit/jobs/test_usage_path.py | 87 +++ server/tests/unit/jobs/test_utils_batches.py | 27 + server/tests/unit/services/__init__.py | 0 .../unit/services/test_analytics_service.py | 118 ++++ .../unit/services/test_annotations_service.py | 180 ++++++ .../unit/services/test_assemblies_service.py | 91 +++ .../unit/services/test_bioproject_service.py | 33 ++ .../unit/services/test_jobs_service_auth.py | 111 ++++ .../unit/services/test_organism_service.py | 35 ++ .../unit/services/test_taxonomy_service.py | 132 +++++ .../services/test_upload_gff_validation.py | 163 ++++++ server/tests/unit/test_health.py | 9 + 89 files changed, 5769 insertions(+), 188 deletions(-) create mode 100644 .github/workflows/front-test.yml create mode 100644 .github/workflows/main-pipeline.yml create mode 100644 .github/workflows/server-test.yml create mode 100644 front/lib/analytics-params.ts create mode 100644 front/lib/annotation-display.test.ts create mode 100644 front/lib/annotation-metric-values.test.ts create mode 100644 front/lib/annotations-tsv-fields.test.ts create mode 100644 front/lib/api/analytics.test.ts create mode 100644 front/lib/api/annotations.test.ts create mode 100644 front/lib/api/assemblies.test.ts create mode 100644 front/lib/api/base.test.ts create mode 100644 front/lib/api/bioprojects.test.ts create mode 100644 front/lib/api/files.test.ts create mode 100644 front/lib/api/organisms.test.ts create mode 100644 front/lib/api/taxons.test.ts create mode 100644 front/lib/custom-annotations-jsonl.test.ts create mode 100644 front/lib/custom-upload-session.test.ts create mode 100644 front/lib/test/mock-fetch.test.ts create mode 100644 front/lib/test/mock-fetch.ts create mode 100644 front/lib/utils.test.ts create mode 100644 server/pytest.ini create mode 100644 server/requirements-dev.txt create mode 100644 server/tests/conftest.py create mode 100644 server/tests/integration/.gitkeep create mode 100644 server/tests/integration/__init__.py create mode 100644 server/tests/integration/conftest.py create mode 100644 server/tests/integration/factories.py create mode 100644 server/tests/integration/test_analytics.py create mode 100644 server/tests/integration/test_annotation_detail.py create mode 100644 server/tests/integration/test_annotation_stats.py create mode 100644 server/tests/integration/test_annotations_list.py create mode 100644 server/tests/integration/test_annotations_report.py create mode 100644 server/tests/integration/test_assemblies.py create mode 100644 server/tests/integration/test_jobs_auth.py create mode 100644 server/tests/integration/test_organisms_bioprojects.py create mode 100644 server/tests/integration/test_taxonomy_flattened.py create mode 100644 server/tests/integration/test_upload_gff.py delete mode 100644 server/tests/test_tsv_fields.py create mode 100644 server/tests/unit/__init__.py create mode 100644 server/tests/unit/fakes.py create mode 100644 server/tests/unit/helpers/__init__.py create mode 100644 server/tests/unit/helpers/test_busco_stats.py create mode 100644 server/tests/unit/helpers/test_feature_stats.py create mode 100644 server/tests/unit/helpers/test_flattened_taxonomy_export.py rename server/tests/{ => unit/helpers}/test_parameters.py (99%) create mode 100644 server/tests/unit/helpers/test_tsv_fields.py create mode 100644 server/tests/unit/jobs/__init__.py create mode 100644 server/tests/unit/jobs/test_annotation_job_helpers.py create mode 100644 server/tests/unit/jobs/test_assemblies_task.py create mode 100644 server/tests/unit/jobs/test_assembly_helpers.py create mode 100644 server/tests/unit/jobs/test_assembly_summary.py create mode 100644 server/tests/unit/jobs/test_contigs_helpers.py create mode 100644 server/tests/unit/jobs/test_feature_stats_lines.py create mode 100644 server/tests/unit/jobs/test_feature_summary.py create mode 100644 server/tests/unit/jobs/test_import_annotations_task.py create mode 100644 server/tests/unit/jobs/test_migration_tasks.py create mode 100644 server/tests/unit/jobs/test_stats_helpers.py create mode 100644 server/tests/unit/jobs/test_taxonomy_export_task.py create mode 100644 server/tests/unit/jobs/test_taxonomy_job_helpers.py create mode 100644 server/tests/unit/jobs/test_track_users_task.py create mode 100644 server/tests/unit/jobs/test_updates_tasks.py create mode 100644 server/tests/unit/jobs/test_upload_gff_task.py create mode 100644 server/tests/unit/jobs/test_usage_path.py create mode 100644 server/tests/unit/jobs/test_utils_batches.py create mode 100644 server/tests/unit/services/__init__.py create mode 100644 server/tests/unit/services/test_analytics_service.py create mode 100644 server/tests/unit/services/test_annotations_service.py create mode 100644 server/tests/unit/services/test_assemblies_service.py create mode 100644 server/tests/unit/services/test_bioproject_service.py create mode 100644 server/tests/unit/services/test_jobs_service_auth.py create mode 100644 server/tests/unit/services/test_organism_service.py create mode 100644 server/tests/unit/services/test_taxonomy_service.py create mode 100644 server/tests/unit/services/test_upload_gff_validation.py create mode 100644 server/tests/unit/test_health.py diff --git a/.github/workflows/build-all.yml b/.github/workflows/build-all.yml index 11abe60..02ccfd8 100644 --- a/.github/workflows/build-all.yml +++ b/.github/workflows/build-all.yml @@ -1,8 +1,7 @@ name: Build and Push on: - push: - branches: [main] + workflow_call: workflow_dispatch: jobs: diff --git a/.github/workflows/front-test.yml b/.github/workflows/front-test.yml new file mode 100644 index 0000000..9708b0b --- /dev/null +++ b/.github/workflows/front-test.yml @@ -0,0 +1,29 @@ +name: Front unit tests + +on: + pull_request: + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: front + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: front/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run unit tests + run: npm test diff --git a/.github/workflows/main-pipeline.yml b/.github/workflows/main-pipeline.yml new file mode 100644 index 0000000..8cacc77 --- /dev/null +++ b/.github/workflows/main-pipeline.yml @@ -0,0 +1,18 @@ +name: Main pipeline (test then build) + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + front-tests: + uses: ./.github/workflows/front-test.yml + + server-tests: + uses: ./.github/workflows/server-test.yml + + build-and-push: + needs: [front-tests, server-tests] + uses: ./.github/workflows/build-all.yml + secrets: inherit diff --git a/.github/workflows/server-test.yml b/.github/workflows/server-test.yml new file mode 100644 index 0000000..817f2ce --- /dev/null +++ b/.github/workflows/server-test.yml @@ -0,0 +1,73 @@ +name: Server tests + +on: + pull_request: + workflow_call: + inputs: + run_integration: + description: "Also run pytest -m integration" + type: boolean + default: false + workflow_dispatch: + inputs: + run_integration: + description: "Also run pytest -m integration" + type: boolean + default: false + schedule: + # Weekly Monday 06:00 UTC — integration suite (mongomock, no Docker services) + - cron: "0 6 * * 1" + +jobs: + unit: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: server/requirements-dev.txt + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Run unit tests with coverage + run: | + coverage run -m pytest -m unit -q + coverage report -m + + integration: + # schedule: always run integration weekly. + # workflow_dispatch / workflow_call: only when run_integration is true. + if: > + github.event_name == 'schedule' || + ((github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') && inputs.run_integration) + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: server/requirements-dev.txt + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Run integration tests + run: pytest -m integration -q diff --git a/.gitignore b/.gitignore index 1dbe346..804087f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__ *.db .DS_Store venv +.venv files/ .env docker-compose-dev.yml @@ -19,4 +20,7 @@ annotrieve_missing_from_tracker.tsv broken_source_urls.jsonl TODO.txt .pytest_cache/ +.coverage +htmlcov/ +coverage.xml graphify-out/ \ No newline at end of file diff --git a/front/lib/analytics-params.ts b/front/lib/analytics-params.ts new file mode 100644 index 0000000..63c3f2d --- /dev/null +++ b/front/lib/analytics-params.ts @@ -0,0 +1,48 @@ +import { buildParamsFromFilters } from "@/lib/utils" +import type { FiltersState } from "@/lib/stores/annotations-filters" + +export type AnalyticsDataSource = "current" | "subsets" + +export interface AnalyticsParamsEntry { + id: string + name: string + color?: string + params: Record +} + +export interface AnalyticsSubsetInput { + id: string + name: string + color?: string + filters: FiltersState +} + +/** + * Build stable { id, name, color, params } entries for analytics fetches. + * - "current" → one entry from currentParams (limit/offset stripped) + * - "subsets" → one entry per selected subset via buildParamsFromFilters + */ +export function buildAnalyticsParamsEntries(opts: { + dataSource: AnalyticsDataSource + selectedSubsetIds: string[] + currentParams: Record + subsets: AnalyticsSubsetInput[] +}): AnalyticsParamsEntry[] { + const { dataSource, selectedSubsetIds, currentParams, subsets } = opts + + if (dataSource === "current") { + const params = { ...currentParams } + delete params.limit + delete params.offset + return [{ id: "current", name: "Current filters", params }] + } + + return subsets + .filter((s) => selectedSubsetIds.includes(s.id)) + .map((s) => ({ + id: s.id, + name: s.name, + color: s.color, + params: buildParamsFromFilters(s.filters), + })) +} diff --git a/front/lib/annotation-display.test.ts b/front/lib/annotation-display.test.ts new file mode 100644 index 0000000..9e64db7 --- /dev/null +++ b/front/lib/annotation-display.test.ts @@ -0,0 +1,137 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { + mergeFavoriteAnnotations, + migrateToCustomAnnotation, + migrateToPortalAnnotation, + remoteFavoriteIds, + toPortalAnnotation, +} from "./annotation-display" +import type { CustomAnnotation, PortalAnnotation } from "@/lib/types" + +const summary = { genes: { coding: 1 } } as never + +function portal(id: string, name = "Org"): PortalAnnotation { + return { + kind: "portal", + annotation_id: id, + taxid: "9606", + taxon_lineage: [], + organism_name: name, + assembly_accession: "GCA_1", + assembly_name: "asm", + source_file_info: { + database: "RefSeq", + url: "https://example.com", + last_modified: "2020-01-01", + }, + indexed_file_info: { + uncompressed_md5: id, + file_size: 1, + bgzipped_path: "/x", + }, + features_summary: summary, + } +} + +function custom(id: string, name = "Custom"): CustomAnnotation { + return { + kind: "custom", + annotation_id: id, + custom_name: name, + uploaded_md5: id, + uploaded_at: "2026-01-01T00:00:00Z", + uploaded_file_size: 10, + features_summary: summary, + } +} + +describe("remoteFavoriteIds", () => { + it("filters out custom ids", () => { + assert.deepEqual( + remoteFavoriteIds(["a", "b", "c"], new Set(["b"])), + ["a", "c"], + ) + }) +}) + +describe("mergeFavoriteAnnotations", () => { + it("dedupes remote+cart+custom; custom only if in cart; custom wins", () => { + const remote = [portal("r1"), portal("shared", "Remote")] + const cart = [portal("c1"), custom("shared", "From cart"), custom("only-custom")] + const customs = [custom("shared", "From store"), custom("orphaned")] + const merged = mergeFavoriteAnnotations(cart, remote, customs) + const byId = Object.fromEntries(merged.map((a) => [a.annotation_id, a])) + + assert.ok(byId.r1) + assert.ok(byId.c1) + assert.equal(byId.shared.kind, "custom") + assert.equal((byId.shared as CustomAnnotation).custom_name, "From store") + assert.ok(byId["only-custom"]) + assert.equal(byId.orphaned, undefined) + }) +}) + +describe("migrateToPortalAnnotation / migrateToCustomAnnotation", () => { + it("accepts minimal valid portal payload", () => { + const migrated = migrateToPortalAnnotation({ + annotation_id: "p1", + organism_name: "Homo sapiens", + features_summary: summary, + source_file_info: { database: "RefSeq", url: "u", last_modified: "t" }, + }) + assert.ok(migrated) + assert.equal(migrated!.kind, "portal") + assert.equal(migrated!.organism_name, "Homo sapiens") + }) + + it("rejects incomplete portal payload", () => { + assert.equal( + migrateToPortalAnnotation({ annotation_id: "p1", features_summary: summary }), + null, + ) + }) + + it("accepts minimal valid custom payload", () => { + const migrated = migrateToCustomAnnotation({ + kind: "custom", + annotation_id: "c1", + features_summary: summary, + custom_name: "Mine", + }) + assert.ok(migrated) + assert.equal(migrated!.kind, "custom") + }) + + it("rejects incomplete custom payload", () => { + assert.equal(migrateToCustomAnnotation({ annotation_id: "c1" }), null) + }) +}) + +describe("toPortalAnnotation", () => { + it("accepts portal shape and rejects incomplete", () => { + const ann = toPortalAnnotation({ + annotation_id: "p1", + organism_name: "Homo sapiens", + features_summary: summary, + source_file_info: { database: "RefSeq", url: "u", last_modified: "t" }, + }) + assert.ok(ann) + assert.equal(ann!.kind, "portal") + assert.equal(toPortalAnnotation(null), null) + assert.equal( + toPortalAnnotation({ annotation_id: "x", features_summary: summary }), + null, + ) + }) + + it("coerces minimal organism_name + summary to portal", () => { + const ann = toPortalAnnotation({ + annotation_id: "loose", + organism_name: "Org", + features_summary: summary, + }) + assert.ok(ann) + assert.equal(ann!.kind, "portal") + }) +}) diff --git a/front/lib/annotation-display.ts b/front/lib/annotation-display.ts index 1ea8cb6..608943f 100644 --- a/front/lib/annotation-display.ts +++ b/front/lib/annotation-display.ts @@ -136,6 +136,21 @@ export function normalizeAnnotation(raw: unknown): Annotation | null { return migrateToCustomAnnotation(record) ?? migrateToPortalAnnotation(record) } +/** Normalize API/store payloads into a portal annotation for overview open. */ +export function toPortalAnnotation(raw: unknown): PortalAnnotation | null { + if (!raw || typeof raw !== "object") return null + const record = raw as Record + const migrated = migrateToPortalAnnotation(record) ?? normalizeAnnotation(record) + if (migrated && isPortalAnnotation(migrated)) return migrated + if (record.annotation_id && record.features_summary && record.organism_name) { + return { + ...record, + kind: "portal", + } as PortalAnnotation + } + return null +} + export function getAnnotationDisplayName(a: Annotation): string { if (isCustomAnnotation(a)) return a.custom_name.trim() || a.annotation_id return a.organism_name?.trim() || a.assembly_name || a.annotation_id @@ -160,6 +175,11 @@ export function sortAnnotationsCustomFirst(annotations: Annotation[]): Annotatio }) } +/** Favorite IDs that need a portal API fetch (exclude custom uploads). */ +export function remoteFavoriteIds(favoriteIds: string[], customIds: Set): string[] { + return favoriteIds.filter((id) => !customIds.has(id)) +} + /** * Merge favorites from cart, API, and custom-annotations store (deduped by annotation_id). * Custom store entries are included only when still present in the cart. diff --git a/front/lib/annotation-metric-values.test.ts b/front/lib/annotation-metric-values.test.ts new file mode 100644 index 0000000..8344ded --- /dev/null +++ b/front/lib/annotation-metric-values.test.ts @@ -0,0 +1,108 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { + extractAnnotationMetricValue, + filterFiniteMetricValues, + meanOf, + medianOf, +} from "./annotation-metric-values" +import type { AnnotationBase } from "@/lib/types" + +const summary = { + root_type_counts: {}, + attribute_keys: [], + types: ["gene"], + sources: ["RefSeq"], + biotypes: [], + root_types: [], + types_missing_id: [], + has_biotype: false, + has_cds: true, + has_exon: true, +} + +function annotation(stats: AnnotationBase["features_statistics"], busco?: Record): AnnotationBase & { busco?: Record } { + return { + annotation_id: "a1", + features_summary: summary, + features_statistics: stats, + busco, + } +} + +describe("extractAnnotationMetricValue", () => { + it("reads gene category total_count with aliases", () => { + const ann = annotation({ + gene_category_stats: { + coding_genes: { total_count: 42, length_stats: { mean: 100 } }, + }, + }) + assert.equal( + extractAnnotationMetricValue(ann, "genes", "coding", "total_count"), + 42, + ) + assert.equal( + extractAnnotationMetricValue(ann, "genes", "coding", "average_mean_length"), + 100, + ) + }) + + it("reads transcript type metrics", () => { + const ann = annotation({ + transcript_type_stats: { + mRNA: { + total_count: 7, + length_stats: { mean: 50 }, + associated_genes: { total_count: 3 }, + exon_stats: { + total_count: 10, + length: { mean: 20 }, + concatenated_length: { mean: 200 }, + }, + cds_stats: { + total_count: 5, + length: { mean: 15 }, + concatenated_length: { mean: 150 }, + }, + }, + }, + }) + assert.equal( + extractAnnotationMetricValue(ann, "transcripts", "mRNA", "total_count"), + 7, + ) + assert.equal( + extractAnnotationMetricValue( + ann, + "transcripts", + "mRNA", + "associated_genes_total_count", + ), + 3, + ) + }) + + it("reads busco metrics", () => { + const ann = annotation(undefined, { complete: 95.5, missing: 1 }) + assert.equal(extractAnnotationMetricValue(ann, "busco", "", "complete"), 95.5) + assert.equal(extractAnnotationMetricValue(ann, "busco", "", "missing"), 1) + assert.equal(extractAnnotationMetricValue(ann, "busco", "", "unknown"), null) + }) +}) + +describe("filterFiniteMetricValues / meanOf / medianOf", () => { + it("filters non-finite values", () => { + assert.deepEqual( + filterFiniteMetricValues([1, "x", NaN, Infinity, 2, null]), + [1, 2], + ) + }) + + it("meanOf and medianOf handle empty and populated", () => { + assert.equal(meanOf([]), null) + assert.equal(medianOf([]), null) + assert.equal(meanOf([2, 4, 6]), 4) + assert.equal(medianOf([1, 3, 2]), 2) + assert.equal(medianOf([1, 2, 3, 4]), 2.5) + }) +}) diff --git a/front/lib/annotation-metric-values.ts b/front/lib/annotation-metric-values.ts index 227dec0..5830c7b 100644 --- a/front/lib/annotation-metric-values.ts +++ b/front/lib/annotation-metric-values.ts @@ -114,3 +114,22 @@ export function extractAnnotationMetricValues( if (value == null || value <= 0) return [] return [value] } + +/** Keep only finite numbers from mixed API / store payloads. */ +export function filterFiniteMetricValues(values: unknown[]): number[] { + return values.filter((v): v is number => typeof v === "number" && Number.isFinite(v)) +} + +export function meanOf(values: number[]): number | null { + if (values.length === 0) return null + return values.reduce((a, b) => a + b, 0) / values.length +} + +export function medianOf(values: number[]): number | null { + if (values.length === 0) return null + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid] +} diff --git a/front/lib/annotations-tsv-fields.test.ts b/front/lib/annotations-tsv-fields.test.ts new file mode 100644 index 0000000..28a2af9 --- /dev/null +++ b/front/lib/annotations-tsv-fields.test.ts @@ -0,0 +1,35 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { + buildSelectedFieldsParam, + getAssemblyTsvFields, + getExtendedTsvFields, +} from "./annotations-tsv-fields" + +describe("annotations-tsv-fields", () => { + it("getAssemblyTsvFields returns three assembly_* keys", () => { + const fields = getAssemblyTsvFields() + assert.equal(fields.length, 3) + assert.deepEqual( + fields.map((f) => f.key).sort(), + [ + "assembly_download_url", + "assembly_gc_percent", + "assembly_refseq_category", + ], + ) + }) + + it("getExtendedTsvFields excludes assembly and deprecated", () => { + const keys = getExtendedTsvFields().map((f) => f.key) + assert.equal(keys.includes("mapped_regions"), false) + assert.equal(keys.some((k) => k.startsWith("assembly_")), false) + assert.ok(keys.includes("busco_complete")) + }) + + it("buildSelectedFieldsParam joins keys or returns undefined", () => { + assert.equal(buildSelectedFieldsParam([]), undefined) + assert.equal(buildSelectedFieldsParam(["a", "b"]), "a,b") + assert.equal(buildSelectedFieldsParam(["a", "", "b"]), "a,b") + }) +}) diff --git a/front/lib/annotations-tsv-fields.ts b/front/lib/annotations-tsv-fields.ts index 05f3894..44653fc 100644 --- a/front/lib/annotations-tsv-fields.ts +++ b/front/lib/annotations-tsv-fields.ts @@ -24,8 +24,13 @@ const TSV_FIELD_GROUP_LABELS: Record = { feature_summary: "Feature summary", gene_stats: "Gene statistics", deprecated: "Deprecated", + assembly: "Assembly", } +// Groups that are handled by their own dedicated UI section instead of the +// generic "Additional columns" list (see getAssemblyTsvFields / download-tsv-dialog.tsx). +const EXTENDED_GROUP_EXCLUDE_LIST = new Set(["deprecated", "assembly"]) + const TSV_FIELD_META: Array<{ key: string label: string @@ -79,6 +84,10 @@ const TSV_FIELD_META: Array<{ { key: "pseudogene_gene_count", label: "Pseudogene count", group: "gene_stats", isDefault: false }, { key: "pseudogene_gene_length_mean", label: "Pseudogene length mean", group: "gene_stats", isDefault: false }, { key: "mapped_regions", label: "Mapped regions (deprecated)", group: "deprecated", isDefault: false }, + // Assembly (joined from the parent GenomeAssembly model) + { key: "assembly_refseq_category", label: "RefSeq category (reference genome)", group: "assembly", isDefault: false }, + { key: "assembly_download_url", label: "Assembly download URL", group: "assembly", isDefault: false }, + { key: "assembly_gc_percent", label: "Assembly GC content (%)", group: "assembly", isDefault: false }, ] function buildFieldGroups(includeDefault: boolean): TsvFieldGroup[] { @@ -115,7 +124,7 @@ export function getDefaultTsvFields(): TsvFieldDefinition[] { export function getExtendedTsvFields(): TsvFieldDefinition[] { return TSV_FIELD_META.filter( - (field) => !field.isDefault && field.group !== "deprecated" + (field) => !field.isDefault && !EXTENDED_GROUP_EXCLUDE_LIST.has(field.group) ).map((field) => ({ key: field.key, label: field.label, @@ -123,6 +132,14 @@ export function getExtendedTsvFields(): TsvFieldDefinition[] { })) } +export function getAssemblyTsvFields(): TsvFieldDefinition[] { + return TSV_FIELD_META.filter((field) => field.group === "assembly").map((field) => ({ + key: field.key, + label: field.label, + isDefault: false, + })) +} + export function buildSelectedFieldsParam(checkedKeys: Iterable): string | undefined { const keys = Array.from(checkedKeys).filter(Boolean) return keys.length > 0 ? keys.join(",") : undefined diff --git a/front/lib/api/analytics.test.ts b/front/lib/api/analytics.test.ts new file mode 100644 index 0000000..5c3839a --- /dev/null +++ b/front/lib/api/analytics.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, it } from "node:test" +import assert from "node:assert/strict" +import { + getCountryFrequencies, + getTopCountries, + getTopEntities, + getUsageCapabilities, + getUsageSummary, +} from "./analytics" +import { + getFetchCalls, + mockJsonResponse, + uninstallFetchMock, + withFetchHandler, +} from "@/lib/test/mock-fetch" + +afterEach(() => { + uninstallFetchMock() +}) + +describe("analytics API clients", () => { + it("getCountryFrequencies", async () => { + withFetchHandler(() => mockJsonResponse({ Spain: 3 })) + const data = await getCountryFrequencies() + assert.deepEqual(data, { Spain: 3 }) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/analytics/frequencies/country", + ) + }) + + it("getUsageSummary", async () => { + withFetchHandler(() => + mockJsonResponse({ + unique_users: 1, + active_30d: 1, + countries: 1, + returning_pct: 0, + as_of: "2026-01-01", + }), + ) + await getUsageSummary() + assert.equal(getFetchCalls()[0].url, "/annotrieve/api/v0/analytics/summary") + }) + + it("getTopCountries passes limit", async () => { + withFetchHandler(() => mockJsonResponse([])) + await getTopCountries(15) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/analytics/top-countries?limit=15", + ) + }) + + it("getUsageCapabilities and getTopEntities", async () => { + withFetchHandler(() => mockJsonResponse({ items: [], as_of: null })) + await getUsageCapabilities() + withFetchHandler(() => + mockJsonResponse({ + top_assemblies: [], + top_annotations: [], + top_taxons: [], + as_of: null, + }), + ) + await getTopEntities() + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/analytics/top-entities", + ) + }) +}) diff --git a/front/lib/api/annotations.test.ts b/front/lib/api/annotations.test.ts new file mode 100644 index 0000000..6db2505 --- /dev/null +++ b/front/lib/api/annotations.test.ts @@ -0,0 +1,279 @@ +import { afterEach, describe, it } from "node:test" +import assert from "node:assert/strict" +import { + downloadAnnotationsReport, + getAnnotation, + getAnnotationsAggregatesByTaxonRank, + getBuscoMetricValues, + getBuscoStats, + getGeneCategoryMetricValues, + getGeneStats, + getTranscriptStats, + getUploadJobStatus, + getUploadRateLimit, + listAnnotations, + listAnnotationsByMd5Checksums, + uploadCustomGff, +} from "./annotations" +import { + getFetchCalls, + mockBlobResponse, + mockJsonResponse, + mockTextResponse, + uninstallFetchMock, + withFetchHandler, +} from "@/lib/test/mock-fetch" + +afterEach(() => { + uninstallFetchMock() +}) + +const page = { total: 1, offset: 0, limit: 20, results: [{ annotation_id: "abc" }] } + +describe("listAnnotations", () => { + it("GETs /annotations with filter and pagination query", async () => { + withFetchHandler(() => mockJsonResponse(page)) + + const data = await listAnnotations({ + filter: "homo", + limit: 20, + offset: 5, + }) + + assert.equal(data.total, 1) + const call = getFetchCalls()[0] + assert.equal(call.method, "GET") + assert.equal( + call.url, + "/annotrieve/api/v0/annotations?filter=homo&limit=20&offset=5", + ) + }) +}) + +describe("listAnnotationsByMd5Checksums", () => { + it("POSTs md5_checksums with default limit length+1", async () => { + withFetchHandler(() => mockJsonResponse(page)) + + await listAnnotationsByMd5Checksums(["aaa", "bbb"]) + + const call = getFetchCalls()[0] + assert.equal(call.method, "POST") + assert.equal(call.url, "/annotrieve/api/v0/annotations") + assert.deepEqual(JSON.parse(String(call.body)), { + md5_checksums: ["aaa", "bbb"], + limit: 3, + offset: 0, + }) + }) +}) + +describe("getAnnotation", () => { + it("GETs /annotations/{md5}", async () => { + withFetchHandler(() => mockJsonResponse({ annotation_id: "deadbeef" })) + + const data = await getAnnotation("deadbeef") + assert.equal(data.annotation_id, "deadbeef") + assert.equal(getFetchCalls()[0].url, "/annotrieve/api/v0/annotations/deadbeef") + }) + + it("throws on 404", async () => { + withFetchHandler(() => mockJsonResponse({ detail: "missing" }, { status: 404 })) + + await assert.rejects( + () => getAnnotation("missing"), + /GET \/annotations\/missing failed: 404/, + ) + }) +}) + +describe("downloadAnnotationsReport", () => { + it("GETs /annotations/report as TSV blob including selected_fields", async () => { + const blob = new Blob(["annotation_id\tx\n"], { + type: "text/tab-separated-values", + }) + withFetchHandler(() => mockBlobResponse(blob)) + + const result = await downloadAnnotationsReport({ + selected_fields: "assembly_gc_percent,taxon_lineage", + }) + + assert.ok(result instanceof Blob) + assert.equal(await result.text(), "annotation_id\tx\n") + + const call = getFetchCalls()[0] + assert.equal(call.method, "GET") + assert.equal( + call.url, + "/annotrieve/api/v0/annotations/report?selected_fields=assembly_gc_percent%2Ctaxon_lineage", + ) + const headers = new Headers(call.headers) + assert.equal(headers.get("Accept"), "text/tab-separated-values") + }) + + it("throws on non-OK", async () => { + withFetchHandler(() => mockTextResponse("nope", { status: 500 })) + + await assert.rejects( + () => downloadAnnotationsReport({}), + /GET \/annotations\/report failed: 500/, + ) + }) +}) + +describe("stats clients", () => { + it("getGeneStats hits /annotations/gene-stats with filter query", async () => { + withFetchHandler(() => + mockJsonResponse({ + total_annotations: 1, + summary: { genes: {} }, + categories: [], + metrics: [], + }), + ) + + await getGeneStats({ taxids: "9606" }) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/annotations/gene-stats?taxids=9606", + ) + }) + + it("getTranscriptStats hits /annotations/transcript-stats", async () => { + withFetchHandler(() => + mockJsonResponse({ + total_annotations: 0, + summary: { types: {} }, + types: [], + metrics: [], + }), + ) + + await getTranscriptStats() + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/annotations/transcript-stats", + ) + }) + + it("getBuscoStats hits /annotations/busco-stats", async () => { + withFetchHandler(() => + mockJsonResponse({ total_annotations: 0, summary: {}, metrics: [] }), + ) + + await getBuscoStats({ filter: "x" }) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/annotations/busco-stats?filter=x", + ) + }) + + it("getGeneCategoryMetricValues encodes path and include_annotations", async () => { + withFetchHandler(() => + mockJsonResponse({ + category: "coding", + metric: "total_count", + values: [1], + missing: [], + }), + ) + + await getGeneCategoryMetricValues("coding", "total_count", { + include_annotations: true, + }) + + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/annotations/gene-stats/coding/total_count?include_annotations=true", + ) + }) + + it("getBuscoMetricValues encodes metric and include_annotations", async () => { + withFetchHandler(() => + mockJsonResponse({ metric: "complete", values: [90] }), + ) + + await getBuscoMetricValues("complete", { include_annotations: true }) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/annotations/busco-stats/complete?include_annotations=true", + ) + }) +}) + +describe("getAnnotationsAggregatesByTaxonRank", () => { + it("GETs aggregates/taxons with required rank", async () => { + withFetchHandler(() => + mockJsonResponse({ + fields: ["taxid", "taxon_name"], + rows: [[1, "Mammalia"]], + }), + ) + + await getAnnotationsAggregatesByTaxonRank("class") + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/annotations/aggregates/taxons?rank=class", + ) + }) +}) + +describe("upload flow", () => { + it("uploadCustomGff POSTs FormData with file and custom_name", async () => { + withFetchHandler(() => + mockJsonResponse({ task_id: "task-1", remaining_quota: 4 }), + ) + + const file = new File(["##gff-version 3\n"], "demo.gff", { + type: "text/plain", + }) + const result = await uploadCustomGff(file, "My upload") + + assert.deepEqual(result, { task_id: "task-1", remaining_quota: 4 }) + const call = getFetchCalls()[0] + assert.equal(call.method, "POST") + assert.equal(call.url, "/annotrieve/api/v0/annotations/upload-gff") + assert.ok(call.body instanceof FormData) + const form = call.body as FormData + assert.equal(form.get("custom_name"), "My upload") + assert.ok(form.get("file") instanceof File) + }) + + it("uploadCustomGff throws on 429", async () => { + withFetchHandler(() => + mockTextResponse("Daily upload limit reached", { status: 429 }), + ) + + const file = new File(["x"], "demo.gff") + await assert.rejects( + () => uploadCustomGff(file, "blocked"), + /Daily upload limit reached/, + ) + }) + + it("getUploadJobStatus hits jobs path with no-store headers", async () => { + withFetchHandler(() => + mockJsonResponse({ task_id: "task-1", state: "SUCCESS" }), + ) + + await getUploadJobStatus("task-1") + const call = getFetchCalls()[0] + assert.equal( + call.url, + "/annotrieve/api/v0/annotations/upload-gff/jobs/task-1", + ) + const headers = new Headers(call.headers) + assert.equal(headers.get("Cache-Control"), "no-cache") + assert.equal(headers.get("Accept"), "application/json") + }) + + it("getUploadRateLimit hits rate-limit path", async () => { + withFetchHandler(() => mockJsonResponse({ used: 1, remaining: 4 })) + + const data = await getUploadRateLimit() + assert.deepEqual(data, { used: 1, remaining: 4 }) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/annotations/upload-gff/rate-limit", + ) + }) +}) diff --git a/front/lib/api/assemblies.test.ts b/front/lib/api/assemblies.test.ts new file mode 100644 index 0000000..6e386ba --- /dev/null +++ b/front/lib/api/assemblies.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, it } from "node:test" +import assert from "node:assert/strict" +import { + getAssembliesStats, + getAssembly, + getChrAliases, + getPairedAssembly, + listAssemblies, +} from "./assemblies" +import { + getFetchCalls, + mockJsonResponse, + uninstallFetchMock, + withFetchHandler, +} from "@/lib/test/mock-fetch" + +afterEach(() => { + uninstallFetchMock() +}) + +describe("assemblies API clients", () => { + it("listAssemblies GETs /assemblies with filters", async () => { + withFetchHandler(() => + mockJsonResponse({ total: 0, offset: 0, limit: 20, results: [] }), + ) + await listAssemblies({ filter: "GRCh", limit: 5 }) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/assemblies?filter=GRCh&limit=5", + ) + }) + + it("getAssembly and getPairedAssembly encode accession", async () => { + withFetchHandler(() => mockJsonResponse({ assembly_accession: "GCA_1" })) + await getAssembly("GCA_000001405.29") + await getPairedAssembly("GCA_000001405.29") + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/assemblies/GCA_000001405.29", + ) + assert.equal( + getFetchCalls()[1].url, + "/annotrieve/api/v0/assemblies/GCA_000001405.29/paired", + ) + }) + + it("getAssembliesStats hits frequencies path", async () => { + withFetchHandler(() => mockJsonResponse({ chromosome: 3 })) + await getAssembliesStats({ taxids: "9606" }, "assembly_level") + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/assemblies/frequencies/assembly_level?taxids=9606", + ) + }) + + it("getChrAliases hits chr_aliases path", async () => { + withFetchHandler(() => mockJsonResponse("ok")) + await getChrAliases("GCA/odd") + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/assemblies/GCA%2Fodd/chr_aliases", + ) + }) + + it("throws on non-OK getAssembly", async () => { + withFetchHandler(() => mockJsonResponse({ detail: "missing" }, { status: 404 })) + await assert.rejects( + () => getAssembly("missing"), + /GET \/assemblies\/missing failed: 404/, + ) + }) +}) diff --git a/front/lib/api/base.test.ts b/front/lib/api/base.test.ts new file mode 100644 index 0000000..961a612 --- /dev/null +++ b/front/lib/api/base.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, it } from "node:test" +import assert from "node:assert/strict" +import { apiGet, apiPost, buildQuery } from "./base" +import { + getFetchCalls, + mockBlobResponse, + mockJsonResponse, + uninstallFetchMock, + withFetchHandler, +} from "@/lib/test/mock-fetch" + +afterEach(() => { + uninstallFetchMock() +}) + +describe("buildQuery", () => { + it("returns empty string for empty params", () => { + assert.equal(buildQuery(), "") + assert.equal(buildQuery({}), "") + }) + + it("omits undefined, null, and empty string", () => { + assert.equal( + buildQuery({ a: undefined, b: null, c: "", d: "ok" }), + "?d=ok", + ) + }) + + it("stringifies numbers and booleans", () => { + assert.equal(buildQuery({ limit: 20, flag: true }), "?limit=20&flag=true") + }) + + it("encodes special characters", () => { + assert.equal(buildQuery({ filter: "a b&c" }), "?filter=a+b%26c") + }) +}) + +describe("apiGet", () => { + it("GETs JSON from the default API base with Accept header", async () => { + withFetchHandler(() => mockJsonResponse({ total: 1, results: [] })) + + const data = await apiGet<{ total: number }>("/annotations", { + limit: 10, + offset: 0, + }) + + assert.deepEqual(data, { total: 1, results: [] }) + const call = getFetchCalls()[0] + assert.equal(call.method, "GET") + assert.equal(call.url, "/annotrieve/api/v0/annotations?limit=10&offset=0") + const headers = new Headers(call.headers) + assert.equal(headers.get("Accept"), "application/json") + }) + + it("throws on non-OK responses", async () => { + withFetchHandler(() => mockJsonResponse({ detail: "nope" }, { status: 500 })) + + await assert.rejects( + () => apiGet("/annotations"), + /GET \/annotations failed: 500/, + ) + }) +}) + +describe("apiPost", () => { + it("POSTs JSON body and returns parsed JSON", async () => { + withFetchHandler(() => mockJsonResponse({ total: 2, results: [] })) + + const data = await apiPost<{ total: number }>("/annotations", { + md5_checksums: ["abc"], + limit: 2, + }) + + assert.equal(data.total, 2) + const call = getFetchCalls()[0] + assert.equal(call.method, "POST") + assert.equal(call.url, "/annotrieve/api/v0/annotations") + assert.equal( + call.body, + JSON.stringify({ md5_checksums: ["abc"], limit: 2 }), + ) + const headers = new Headers(call.headers) + assert.equal(headers.get("Content-Type"), "application/json") + }) + + it("returns a blob when responseType is blob", async () => { + const blob = new Blob(["hello"], { type: "application/x-tar" }) + withFetchHandler(() => mockBlobResponse(blob)) + + const result = await apiPost( + "/annotations/download", + { md5_checksums: ["abc"] }, + {}, + {}, + "blob", + ) + + assert.ok(result instanceof Blob) + assert.equal(await result.text(), "hello") + }) + + it("throws on non-OK responses", async () => { + withFetchHandler(() => mockJsonResponse({ detail: "bad" }, { status: 400 })) + + await assert.rejects( + () => apiPost("/annotations", {}), + /POST \/annotations failed: 400/, + ) + }) +}) diff --git a/front/lib/api/base.ts b/front/lib/api/base.ts index c938882..8f9a01d 100644 --- a/front/lib/api/base.ts +++ b/front/lib/api/base.ts @@ -2,8 +2,6 @@ import { getApiBase, joinUrl } from '@/lib/config/env' export type Query = Record -const API_BASE = getApiBase() - export function buildQuery(params: Query = {}): string { const usp = new URLSearchParams() for (const [k, v] of Object.entries(params)) { @@ -15,7 +13,7 @@ export function buildQuery(params: Query = {}): string { } export async function apiGet(path: string, params?: Query, init?: RequestInit): Promise { - const url = `${joinUrl(API_BASE, path)}${buildQuery(params)}` + const url = `${joinUrl(getApiBase(), path)}${buildQuery(params)}` const res = await fetch(url, { ...init, method: 'GET', headers: { 'Accept': 'application/json', ...(init?.headers || {}) } }) if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`) return res.json() as Promise @@ -26,7 +24,7 @@ interface ApiRequestInit extends RequestInit { } export async function apiPost(path: string, body?: any, params?: Query, init?: ApiRequestInit, responseType?: 'json' | 'blob'): Promise { - const url = `${joinUrl(API_BASE, path)}${buildQuery(params)}` + const url = `${joinUrl(getApiBase(), path)}${buildQuery(params)}` const res = await fetch(url, { ...init, method: 'POST', headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, body: JSON.stringify(body) }) if (!res.ok) throw new Error(`POST ${path} failed: ${res.status}`) diff --git a/front/lib/api/bioprojects.test.ts b/front/lib/api/bioprojects.test.ts new file mode 100644 index 0000000..531143c --- /dev/null +++ b/front/lib/api/bioprojects.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, it } from "node:test" +import assert from "node:assert/strict" +import { getBioproject, listBioprojects } from "./bioprojects" +import { + getFetchCalls, + mockJsonResponse, + uninstallFetchMock, + withFetchHandler, +} from "@/lib/test/mock-fetch" + +afterEach(() => { + uninstallFetchMock() +}) + +describe("bioprojects API clients", () => { + it("listBioprojects GETs /bioprojects", async () => { + withFetchHandler(() => + mockJsonResponse({ total: 0, offset: 0, limit: 20, results: [] }), + ) + await listBioprojects({ filter: "PRJ", limit: 5 }) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/bioprojects?filter=PRJ&limit=5", + ) + }) + + it("getBioproject encodes accession", async () => { + withFetchHandler(() => mockJsonResponse({ accession: "PRJNA1" })) + await getBioproject("PRJNA1") + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/bioprojects/PRJNA1", + ) + }) +}) diff --git a/front/lib/api/files.test.ts b/front/lib/api/files.test.ts new file mode 100644 index 0000000..ca64e5f --- /dev/null +++ b/front/lib/api/files.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, it } from "node:test" +import assert from "node:assert/strict" +import { + assemblyHasChromosomesFile, + chrAliasesFileUrl, + chromosomesFileUrl, + contigsFileUrl, + fetchChromosomesFromFiles, + headFile, + resolveChrAliasesFileUrl, +} from "./files" +import { + getFetchCalls, + mockJsonResponse, + mockTextResponse, + uninstallFetchMock, + withFetchHandler, +} from "@/lib/test/mock-fetch" + +afterEach(() => { + uninstallFetchMock() +}) + +describe("files URL builders", () => { + it("builds chromosomes, aliases, and contigs URLs", () => { + assert.equal( + chromosomesFileUrl("9606", "GCA_1"), + "/annotrieve/files/9606/GCA_1/chromosomes.json", + ) + assert.equal( + chrAliasesFileUrl("9606", "GCA_1"), + "/annotrieve/files/9606/GCA_1/chr_aliases.tsv", + ) + assert.equal( + contigsFileUrl("/path/to/file.gff.gz"), + "/annotrieve/files/path/to/file.gff.gz.contigs.txt", + ) + }) +}) + +describe("files fetch helpers", () => { + it("headFile returns true/false from response.ok", async () => { + withFetchHandler(() => mockTextResponse("", { status: 200 })) + assert.equal(await headFile("/annotrieve/files/x"), true) + + withFetchHandler(() => mockTextResponse("", { status: 404 })) + assert.equal(await headFile("/annotrieve/files/missing"), false) + }) + + it("resolveChrAliasesFileUrl prefers primary then paired", async () => { + withFetchHandler((url) => { + if (url.includes("GCA_primary")) return mockTextResponse("", { status: 404 }) + if (url.includes("GCA_paired")) return mockTextResponse("", { status: 200 }) + return mockTextResponse("", { status: 404 }) + }) + const url = await resolveChrAliasesFileUrl("9606", "GCA_primary", "GCA_paired") + assert.equal(url, "/annotrieve/files/9606/GCA_paired/chr_aliases.tsv") + }) + + it("fetchChromosomesFromFiles falls back to paired accession", async () => { + withFetchHandler((url) => { + if (url.includes("GCA_primary")) return mockJsonResponse([], { status: 404 }) + return mockJsonResponse([{ chr_name: "1", length: 100 }]) + }) + const rows = await fetchChromosomesFromFiles("9606", "GCA_primary", "GCA_paired") + assert.equal(rows.length, 1) + assert.equal(rows[0].chr_name, "1") + assert.ok(getFetchCalls().some((c) => c.url.includes("GCA_paired"))) + }) + + it("assemblyHasChromosomesFile checks primary then paired", async () => { + withFetchHandler((url) => { + if (url.includes("GCA_primary")) return mockTextResponse("", { status: 404 }) + return mockTextResponse("", { status: 200 }) + }) + assert.equal( + await assemblyHasChromosomesFile("9606", "GCA_primary", "GCA_paired"), + true, + ) + }) +}) diff --git a/front/lib/api/organisms.test.ts b/front/lib/api/organisms.test.ts new file mode 100644 index 0000000..79caeec --- /dev/null +++ b/front/lib/api/organisms.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, it } from "node:test" +import assert from "node:assert/strict" +import { getOrganism, listOrganisms } from "./organisms" +import { + getFetchCalls, + mockJsonResponse, + uninstallFetchMock, + withFetchHandler, +} from "@/lib/test/mock-fetch" + +afterEach(() => { + uninstallFetchMock() +}) + +describe("organisms API clients", () => { + it("listOrganisms GETs /organisms", async () => { + withFetchHandler(() => + mockJsonResponse({ total: 0, offset: 0, limit: 20, results: [] }), + ) + await listOrganisms({ filter: "homo", limit: 5 }) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/organisms?filter=homo&limit=5", + ) + }) + + it("getOrganism encodes taxid", async () => { + withFetchHandler(() => mockJsonResponse({ taxid: "9606" })) + await getOrganism("9606") + assert.equal(getFetchCalls()[0].url, "/annotrieve/api/v0/organisms/9606") + }) +}) diff --git a/front/lib/api/taxons.test.ts b/front/lib/api/taxons.test.ts new file mode 100644 index 0000000..523ecf8 --- /dev/null +++ b/front/lib/api/taxons.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, it } from "node:test" +import assert from "node:assert/strict" +import { + getFlattenedTree, + getTaxon, + getTaxonAncestors, + getTaxonChildren, + getTaxonRankFrequencies, + listTaxons, + parseTsvToFlatTreeNodes, +} from "./taxons" +import { + getFetchCalls, + mockJsonResponse, + mockTextResponse, + uninstallFetchMock, + withFetchHandler, +} from "@/lib/test/mock-fetch" + +afterEach(() => { + uninstallFetchMock() +}) + +const page = { total: 1, offset: 0, limit: 20, results: [{ taxid: "9606" }] } + +describe("taxons API clients", () => { + it("listTaxons GETs /taxons with query", async () => { + withFetchHandler(() => mockJsonResponse(page)) + await listTaxons({ filter: "homo", limit: 10 }) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/taxons?filter=homo&limit=10", + ) + }) + + it("getTaxon encodes taxid", async () => { + withFetchHandler(() => mockJsonResponse({ taxid: "9606" })) + await getTaxon("9606") + assert.equal(getFetchCalls()[0].url, "/annotrieve/api/v0/taxons/9606") + }) + + it("getTaxonChildren and getTaxonAncestors hit nested paths", async () => { + withFetchHandler(() => mockJsonResponse(page)) + await getTaxonChildren("9606") + await getTaxonAncestors("9606") + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/taxons/9606/children", + ) + assert.equal( + getFetchCalls()[1].url, + "/annotrieve/api/v0/taxons/9606/ancestors", + ) + }) + + it("getTaxonRankFrequencies hits frequencies/rank", async () => { + withFetchHandler(() => mockJsonResponse({ species: 10 })) + await getTaxonRankFrequencies() + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/taxons/frequencies/rank", + ) + }) + + it("getFlattenedTree json returns fields/rows", async () => { + withFetchHandler(() => + mockJsonResponse({ fields: ["taxid"], rows: [["1"]] }), + ) + const data = await getFlattenedTree("json") + assert.deepEqual(data, { fields: ["taxid"], rows: [["1"]] }) + assert.equal( + getFetchCalls()[0].url, + "/annotrieve/api/v0/taxons/flattened-tree?format=json", + ) + }) + + it("getFlattenedTree tsv parses nodes", async () => { + const tsv = + "taxid\tparent_taxid\tscientific_name\tannotations_count\tassemblies_count\torganisms_count\trank\tcoding_mean_count\tnon_coding_mean_count\tpseudogene_mean_count\tmRNA_mean_count\tlncRNA_mean_count\ttRNA_mean_count\tmiRNA_mean_count\tbusco_single_copy_mean\tbusco_duplicated_mean\tbusco_fragmented_mean\tbusco_missing_mean\n" + + "2\t1\tBacteria\t3\t4\t5\tphylum\t1\t2\t3\t4\t5\t6\t7\t8\t9\t10\t11\n" + withFetchHandler(() => mockTextResponse(tsv)) + const nodes = (await getFlattenedTree("tsv")) as ReturnType< + typeof parseTsvToFlatTreeNodes + > + assert.equal(nodes.length, 1) + assert.equal(nodes[0].id, "2") + assert.equal(nodes[0].parentId, "1") + assert.equal(nodes[0].scientific_name, "Bacteria") + }) + + it("getFlattenedTree throws on non-OK", async () => { + withFetchHandler(() => mockTextResponse("nope", { status: 500 })) + await assert.rejects( + () => getFlattenedTree("tsv"), + /GET \/taxons\/flattened-tree failed: 500/, + ) + }) +}) + +describe("parseTsvToFlatTreeNodes", () => { + it("returns empty array for header-only input", () => { + assert.deepEqual(parseTsvToFlatTreeNodes("taxid\tparent_taxid\n"), []) + }) + + it("maps one data row and null parent", () => { + const tsv = + "taxid\tparent_taxid\tscientific_name\tannotations_count\tassemblies_count\torganisms_count\trank\tcoding_mean_count\tnon_coding_mean_count\tpseudogene_mean_count\tmRNA_mean_count\tlncRNA_mean_count\ttRNA_mean_count\tmiRNA_mean_count\tbusco_single_copy_mean\tbusco_duplicated_mean\tbusco_fragmented_mean\tbusco_missing_mean\n" + + "9606\t\tHomo sapiens\t1\t2\t3\tspecies\t10\t20\t30\t1.5\t2.5\t3.5\t4.5\t90\t5\t3\t2\n" + const [node] = parseTsvToFlatTreeNodes(tsv) + assert.equal(node.id, "9606") + assert.equal(node.parentId, null) + assert.equal(node.rank, "species") + assert.equal(node.coding_count, 10) + assert.equal(node.busco_single_copy_mean, 90) + }) +}) diff --git a/front/lib/api/taxons.ts b/front/lib/api/taxons.ts index 50b3a21..2374500 100644 --- a/front/lib/api/taxons.ts +++ b/front/lib/api/taxons.ts @@ -80,7 +80,7 @@ export async function getFlattenedTree(format: 'json' | 'tsv' = 'tsv'): Promise< /** * Parses TSV text into FlatTreeNode array */ -function parseTsvToFlatTreeNodes(tsvText: string): FlatTreeNode[] { +export function parseTsvToFlatTreeNodes(tsvText: string): FlatTreeNode[] { const lines = tsvText.trim().split('\n') if (lines.length === 0) return [] diff --git a/front/lib/custom-annotations-jsonl.test.ts b/front/lib/custom-annotations-jsonl.test.ts new file mode 100644 index 0000000..f39e003 --- /dev/null +++ b/front/lib/custom-annotations-jsonl.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { + applyJsonlImportPreview, + parseCustomAnnotationRecord, + previewCustomAnnotationsJsonlImport, + serializeCustomAnnotationsToJsonl, +} from "./custom-annotations-jsonl" +import type { CustomAnnotation } from "@/lib/types" + +const summary = { + root_type_counts: {}, + attribute_keys: [], + types: ["gene"], + sources: ["custom"], + biotypes: [], + root_types: [], + types_missing_id: [], + has_biotype: false, + has_cds: false, + has_exon: false, +} + +function custom(id: string, name = "Mine"): CustomAnnotation { + return { + kind: "custom", + annotation_id: id, + custom_name: name, + uploaded_md5: id, + uploaded_at: "2026-01-01T00:00:00.000Z", + uploaded_file_size: 10, + features_summary: summary, + } +} + +describe("serializeCustomAnnotationsToJsonl", () => { + it("serializes one object per line", () => { + const text = serializeCustomAnnotationsToJsonl([custom("a"), custom("b")]) + const lines = text.split("\n") + assert.equal(lines.length, 2) + assert.equal(JSON.parse(lines[0]).annotation_id, "a") + }) +}) + +describe("parseCustomAnnotationRecord", () => { + it("accepts a valid custom annotation", () => { + const result = parseCustomAnnotationRecord(custom("md5x") as unknown as Record) + assert.equal(result.ok, true) + if (result.ok) assert.equal(result.annotation.annotation_id, "md5x") + }) + + it("rejects incomplete records", () => { + const result = parseCustomAnnotationRecord({ annotation_id: "x" }) + assert.equal(result.ok, false) + }) +}) + +describe("preview + apply duplicate strategies", () => { + it("classifies new vs duplicate; skip vs overwrite", () => { + const existing = custom("dup", "Old") + const text = [ + JSON.stringify(custom("new1")), + JSON.stringify(custom("dup", "Incoming")), + ].join("\n") + + const preview = previewCustomAnnotationsJsonlImport( + text, + new Map([["dup", existing]]), + ) + assert.equal(preview.newAnnotations.length, 1) + assert.equal(preview.duplicates.length, 1) + + const added: CustomAnnotation[] = [] + const skip = applyJsonlImportPreview(preview, "skip", (a) => added.push(a)) + assert.equal(skip.imported, 1) + assert.equal(skip.skippedDuplicates, 1) + assert.equal(skip.overwritten, 0) + + const overwritten: CustomAnnotation[] = [] + const overwrite = applyJsonlImportPreview(preview, "overwrite", (a) => + overwritten.push(a), + ) + assert.equal(overwrite.imported, 1) + assert.equal(overwrite.overwritten, 1) + assert.equal(overwrite.skippedDuplicates, 0) + }) +}) diff --git a/front/lib/custom-upload-session.test.ts b/front/lib/custom-upload-session.test.ts new file mode 100644 index 0000000..e932691 --- /dev/null +++ b/front/lib/custom-upload-session.test.ts @@ -0,0 +1,123 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { + TERMINAL_JOB_STATES, + annotationFromJson, + getLoadingLabel, + getUploadHeaderPhase, + taskResultToAnnotation, +} from "./custom-upload-session" +import type { UploadSession } from "@/lib/stores/custom-annotations" +import { EMPTY_UPLOAD_SESSION } from "@/lib/stores/custom-annotations" + +function session(patch: Partial = {}): UploadSession { + return { ...EMPTY_UPLOAD_SESSION, ...patch } +} + +const summary = { genes: { coding: 1 } } as never + +describe("getUploadHeaderPhase", () => { + it("returns confirm when session has result", () => { + assert.equal( + getUploadHeaderPhase( + session({ + result: { + kind: "custom", + annotation_id: "abc", + custom_name: "x", + uploaded_md5: "abc", + uploaded_at: "2026-01-01", + uploaded_file_size: 1, + features_summary: summary, + }, + }), + false, + ), + "confirm", + ) + }) + + it("returns loading while submitting or job in flight", () => { + assert.equal(getUploadHeaderPhase(session(), true), "loading") + assert.equal( + getUploadHeaderPhase(session({ jobId: "t1", jobState: "STARTED" }), false), + "loading", + ) + }) + + it("returns error for FAILURE/REVOKED", () => { + assert.equal( + getUploadHeaderPhase(session({ jobId: "t1", jobState: "FAILURE" }), false), + "error", + ) + assert.equal( + getUploadHeaderPhase(session({ jobId: "t1", jobState: "REVOKED" }), false), + "error", + ) + }) + + it("returns idle when no job", () => { + assert.equal(getUploadHeaderPhase(session(), false), "idle") + }) +}) + +describe("TERMINAL_JOB_STATES", () => { + it("includes SUCCESS, FAILURE, REVOKED", () => { + assert.ok(TERMINAL_JOB_STATES.has("SUCCESS")) + assert.ok(TERMINAL_JOB_STATES.has("FAILURE")) + assert.ok(TERMINAL_JOB_STATES.has("REVOKED")) + assert.equal(TERMINAL_JOB_STATES.has("STARTED"), false) + }) +}) + +describe("taskResultToAnnotation / annotationFromJson", () => { + it("maps task result to custom annotation", () => { + const ann = taskResultToAnnotation( + { + annotation_id: "md5a", + features_summary: summary, + indexed_file_info: { uncompressed_md5: "md5a", file_size: 12 }, + computed_at: "2026-02-01T00:00:00Z", + }, + "My upload", + ) + assert.equal(ann.kind, "custom") + assert.equal(ann.annotation_id, "md5a") + assert.equal(ann.custom_name, "My upload") + assert.equal(ann.uploaded_file_size, 12) + }) + + it("annotationFromJson throws without md5/summary", () => { + assert.throws( + () => annotationFromJson({ annotation_id: "x" }, "n"), + /features_summary/, + ) + }) + + it("annotationFromJson happy path", () => { + const ann = annotationFromJson( + { + annotation_id: "md5b", + features_summary: summary, + indexed_file_info: { uncompressed_md5: "md5b", file_size: 3 }, + }, + "Named", + ) + assert.equal(ann.annotation_id, "md5b") + assert.equal(ann.custom_name, "Named") + }) +}) + +describe("getLoadingLabel", () => { + it("prefers submitting, then step, then state", () => { + assert.equal(getLoadingLabel(session(), true), "Uploading…") + assert.equal( + getLoadingLabel(session({ jobStep: "parse_gff" }), false), + "Computing… (parse gff)", + ) + assert.equal( + getLoadingLabel(session({ jobState: "PENDING" }), false), + "Computing… (PENDING)", + ) + }) +}) diff --git a/front/lib/hooks/use-analytics-data.ts b/front/lib/hooks/use-analytics-data.ts index 685eaa8..723735d 100644 --- a/front/lib/hooks/use-analytics-data.ts +++ b/front/lib/hooks/use-analytics-data.ts @@ -3,16 +3,14 @@ import { useMemo } from "react" import { useAnnotationsFiltersStore } from "@/lib/stores/annotations-filters" import { useAnnotationSubsetsStore } from "@/lib/stores/annotation-subsets" -import { buildParamsFromFilters } from "@/lib/utils" +import { + buildAnalyticsParamsEntries, + type AnalyticsParamsEntry, +} from "@/lib/analytics-params" export type DataSource = "current" | "subsets" -export interface ParamsEntry { - id: string - name: string - color?: string - params: Record -} +export type ParamsEntry = AnalyticsParamsEntry interface UseAnalyticsDataOptions { dataSource: DataSource @@ -34,20 +32,12 @@ export function useAnalyticsData({ const subsets = useAnnotationSubsetsStore((state) => state.subsets) return useMemo(() => { - if (dataSource === "current") { - const params = buildAnnotationsParams(false, []) - delete params.limit - delete params.offset - return [{ id: "current", name: "Current filters", params }] - } - - return subsets - .filter((s) => selectedSubsetIds.includes(s.id)) - .map((s) => ({ - id: s.id, - name: s.name, - color: s.color, - params: buildParamsFromFilters(s.filters), - })) + const currentParams = buildAnnotationsParams(false, []) + return buildAnalyticsParamsEntries({ + dataSource, + selectedSubsetIds, + currentParams, + subsets, + }) }, [dataSource, selectedSubsetIds, subsets, buildAnnotationsParams]) } diff --git a/front/lib/hooks/use-annotation-overview-url-sync.ts b/front/lib/hooks/use-annotation-overview-url-sync.ts index 4adff94..9805fe0 100644 --- a/front/lib/hooks/use-annotation-overview-url-sync.ts +++ b/front/lib/hooks/use-annotation-overview-url-sync.ts @@ -15,30 +15,11 @@ import { syncLatestAnnotationsSearch, } from "@/lib/annotations-url-writer" import { getAnnotation } from "@/lib/api/annotations" -import { - isPortalAnnotation, - migrateToPortalAnnotation, - normalizeAnnotation, -} from "@/lib/annotation-display" -import type { PortalAnnotation } from "@/lib/types" +import { toPortalAnnotation } from "@/lib/annotation-display" import { useUIStore } from "@/lib/stores/ui" type AppRouter = ReturnType -function toPortalAnnotation(raw: unknown): PortalAnnotation | null { - if (!raw || typeof raw !== "object") return null - const record = raw as Record - const migrated = migrateToPortalAnnotation(record) ?? normalizeAnnotation(record) - if (migrated && isPortalAnnotation(migrated)) return migrated - if (record.annotation_id && record.features_summary && record.organism_name) { - return { - ...record, - kind: "portal", - } as PortalAnnotation - } - return null -} - function getShownOverviewAnnotationId(): string | null { const { rightSidebar } = useUIStore.getState() if ( diff --git a/front/lib/hooks/use-favorites-reference-data.ts b/front/lib/hooks/use-favorites-reference-data.ts index af4fe9d..94c85f5 100644 --- a/front/lib/hooks/use-favorites-reference-data.ts +++ b/front/lib/hooks/use-favorites-reference-data.ts @@ -7,6 +7,11 @@ import { getGeneCategoryMetricValues, getTranscriptTypeMetricValues, } from "@/lib/api/annotations" +import { + filterFiniteMetricValues, + meanOf, + medianOf, +} from "@/lib/annotation-metric-values" export type EntityType = "genes" | "transcripts" @@ -75,7 +80,7 @@ export function useFavoritesReferenceData({ } if (!cancelled) { - setValues(result.values.filter((v) => typeof v === "number" && isFinite(v))) + setValues(filterFiniteMetricValues(result.values)) } } catch (err) { if (!cancelled) { @@ -95,19 +100,8 @@ export function useFavoritesReferenceData({ } }, [enabled, entityType, categoryOrType, metric, favoriteIds.join(","), buildAnnotationsParams]) - const mean = useMemo(() => { - if (values.length === 0) return null - return values.reduce((a, b) => a + b, 0) / values.length - }, [values]) - - const median = useMemo(() => { - if (values.length === 0) return null - const sorted = [...values].sort((a, b) => a - b) - const mid = Math.floor(sorted.length / 2) - return sorted.length % 2 === 0 - ? (sorted[mid - 1] + sorted[mid]) / 2 - : sorted[mid] - }, [values]) + const mean = useMemo(() => meanOf(values), [values]) + const median = useMemo(() => medianOf(values), [values]) return { values, mean, median, loading, error } } diff --git a/front/lib/hooks/use-merged-favorite-annotations.ts b/front/lib/hooks/use-merged-favorite-annotations.ts index cb6249a..2e55ac6 100644 --- a/front/lib/hooks/use-merged-favorite-annotations.ts +++ b/front/lib/hooks/use-merged-favorite-annotations.ts @@ -2,7 +2,11 @@ import { useState, useEffect, useMemo } from "react" import { listAnnotationsByMd5Checksums } from "@/lib/api/annotations" -import { mergeFavoriteAnnotations, migrateToPortalAnnotation } from "@/lib/annotation-display" +import { + mergeFavoriteAnnotations, + migrateToPortalAnnotation, + remoteFavoriteIds as filterRemoteFavoriteIds, +} from "@/lib/annotation-display" import type { Annotation, CustomAnnotation, PortalAnnotation } from "@/lib/types" export interface UseMergedFavoriteAnnotationsOptions { @@ -34,7 +38,7 @@ export function useMergedFavoriteAnnotations({ ) const remoteFavoriteIds = useMemo( - () => favoriteIds.filter((id) => !customIdsSet.has(id)), + () => filterRemoteFavoriteIds(favoriteIds, customIdsSet), [favoriteIds, customIdsSet], ) diff --git a/front/lib/test/mock-fetch.test.ts b/front/lib/test/mock-fetch.test.ts new file mode 100644 index 0000000..1912a81 --- /dev/null +++ b/front/lib/test/mock-fetch.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, it } from "node:test" +import assert from "node:assert/strict" +import { + getFetchCalls, + installFetchMock, + mockJsonResponse, + uninstallFetchMock, + withFetchHandler, +} from "./mock-fetch" + +afterEach(() => { + uninstallFetchMock() +}) + +describe("mock-fetch", () => { + it("installs a handler that returns staged JSON and records the call", async () => { + withFetchHandler(() => mockJsonResponse({ ok: true })) + + const res = await fetch("/annotrieve/api/v0/annotations", { method: "GET" }) + const body = await res.json() + + assert.equal(res.ok, true) + assert.equal(res.status, 200) + assert.deepEqual(body, { ok: true }) + + const calls = getFetchCalls() + assert.equal(calls.length, 1) + assert.equal(calls[0].url, "/annotrieve/api/v0/annotations") + assert.equal(calls[0].method, "GET") + }) + + it("uninstall restores the previous fetch implementation", async () => { + const original = globalThis.fetch + installFetchMock(() => mockJsonResponse({ mocked: true })) + uninstallFetchMock() + assert.equal(globalThis.fetch, original) + }) +}) diff --git a/front/lib/test/mock-fetch.ts b/front/lib/test/mock-fetch.ts new file mode 100644 index 0000000..a02544f --- /dev/null +++ b/front/lib/test/mock-fetch.ts @@ -0,0 +1,120 @@ +/** + * Minimal fetch stub for node:test API client tests. + * + * withFetchHandler(() => mockJsonResponse({ ok: true })) + * await listFoos() + * assert.equal(getFetchCalls()[0].url, "/annotrieve/api/v0/foos") + * // afterEach: uninstallFetchMock() + */ +export type FetchCall = { + url: string + method: string + headers: HeadersInit | undefined + body: BodyInit | null | undefined +} + +export type FetchHandler = ( + url: string, + init?: RequestInit, +) => Response | Promise + +type FetchFn = typeof globalThis.fetch + +let previousFetch: FetchFn | undefined +let handler: FetchHandler | undefined +const calls: FetchCall[] = [] + +function normalizeUrl(input: RequestInfo | URL): string { + if (typeof input === "string") return input + if (input instanceof URL) return input.toString() + return input.url +} + +export function mockJsonResponse( + body: unknown, + init?: { status?: number; headers?: HeadersInit }, +): Response { + const status = init?.status ?? 200 + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(init?.headers), + json: async () => body, + blob: async () => new Blob([JSON.stringify(body)]), + text: async () => JSON.stringify(body), + } as Response +} + +export function mockBlobResponse( + blob: Blob, + init?: { status?: number; headers?: HeadersInit }, +): Response { + const status = init?.status ?? 200 + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(init?.headers), + json: async () => { + throw new Error("mockBlobResponse: json() not available") + }, + blob: async () => blob, + text: async () => blob.text(), + } as Response +} + +export function mockTextResponse( + text: string, + init?: { status?: number; headers?: HeadersInit }, +): Response { + const status = init?.status ?? 200 + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(init?.headers), + json: async () => JSON.parse(text), + blob: async () => new Blob([text]), + text: async () => text, + } as Response +} + +export function getFetchCalls(): FetchCall[] { + return [...calls] +} + +export function clearFetchCalls(): void { + calls.length = 0 +} + +export function installFetchMock(nextHandler?: FetchHandler): void { + if (previousFetch === undefined) { + previousFetch = globalThis.fetch + } + clearFetchCalls() + handler = nextHandler + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = normalizeUrl(input) + calls.push({ + url, + method: (init?.method ?? "GET").toUpperCase(), + headers: init?.headers, + body: init?.body, + }) + if (!handler) { + throw new Error(`fetch mock installed but no handler for ${url}`) + } + return handler(url, init) + }) as FetchFn +} + +export function uninstallFetchMock(): void { + if (previousFetch !== undefined) { + globalThis.fetch = previousFetch + } + previousFetch = undefined + handler = undefined + clearFetchCalls() +} + +export function withFetchHandler(nextHandler: FetchHandler): void { + installFetchMock(nextHandler) +} diff --git a/front/lib/utils.test.ts b/front/lib/utils.test.ts new file mode 100644 index 0000000..9cb3373 --- /dev/null +++ b/front/lib/utils.test.ts @@ -0,0 +1,91 @@ +import { describe, it } from "node:test" +import assert from "node:assert/strict" +import { buildParamsFromFilters } from "./utils" +import { buildAnalyticsParamsEntries } from "./analytics-params" +import type { FiltersState } from "./stores/annotations-filters" + +const emptyFilters: FiltersState = { + selectedTaxons: [], + selectedOrganisms: [], + selectedAssemblies: [], + selectedBioprojects: [], + selectedAssemblyLevels: [], + selectedAssemblyStatuses: [], + onlyRefGenomes: false, + biotypes: [], + featureTypes: [], + featureSources: [], + pipelines: [], + providers: [], + databaseSources: [], + buscoCompleteFrom: null, + buscoCompleteTo: null, +} + +describe("buildParamsFromFilters", () => { + it("maps taxons, assemblies, refseq, and providers to CSV params", () => { + const params = buildParamsFromFilters({ + ...emptyFilters, + selectedTaxons: [ + { taxid: "9606", scientific_name: "Homo sapiens" } as never, + { taxid: "10090", scientific_name: "Mus musculus" } as never, + ], + selectedAssemblies: [ + { assembly_accession: "GCA_1" } as never, + { assembly_accession: "GCA_2" } as never, + ], + onlyRefGenomes: true, + providers: ["NCBI", "Ensembl, EMBL"], + }) + assert.equal(params.taxids, "9606,10090") + assert.equal(params.assembly_accessions, "GCA_1,GCA_2") + assert.equal(params.refseq_categories, "reference genome") + assert.equal(params.providers, 'NCBI,"Ensembl, EMBL"') + }) + + it("returns empty object for empty filters", () => { + assert.deepEqual(buildParamsFromFilters(emptyFilters), {}) + }) +}) + +describe("buildAnalyticsParamsEntries", () => { + it("current source strips limit/offset", () => { + const entries = buildAnalyticsParamsEntries({ + dataSource: "current", + selectedSubsetIds: [], + currentParams: { taxids: "9606", limit: 20, offset: 0 }, + subsets: [], + }) + assert.equal(entries.length, 1) + assert.equal(entries[0].id, "current") + assert.deepEqual(entries[0].params, { taxids: "9606" }) + }) + + it("subsets source filters by selected ids via buildParamsFromFilters", () => { + const entries = buildAnalyticsParamsEntries({ + dataSource: "subsets", + selectedSubsetIds: ["s2"], + currentParams: {}, + subsets: [ + { + id: "s1", + name: "One", + filters: { ...emptyFilters, providers: ["A"] }, + }, + { + id: "s2", + name: "Two", + color: "#abc", + filters: { + ...emptyFilters, + selectedTaxons: [{ taxid: "1" } as never], + }, + }, + ], + }) + assert.equal(entries.length, 1) + assert.equal(entries[0].id, "s2") + assert.equal(entries[0].color, "#abc") + assert.equal(entries[0].params.taxids, "1") + }) +}) diff --git a/front/package-lock.json b/front/package-lock.json index 26efb36..27a0375 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -85,6 +85,7 @@ "@types/react-dom": "^18", "postcss": "^8.5", "tailwindcss": "^4.1.9", + "tsx": "^4.23.12", "tw-animate-css": "1.3.3", "typescript": "^5" } @@ -425,6 +426,448 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@exodus/schemasafe": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", @@ -5046,6 +5489,48 @@ "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5155,6 +5640,21 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7213,6 +7713,25 @@ } } }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tw-animate-css": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.3.tgz", diff --git a/front/package.json b/front/package.json index c0f8f88..7ba83fb 100644 --- a/front/package.json +++ b/front/package.json @@ -7,7 +7,9 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test:url": "npx tsx --test lib/annotations-url.test.ts lib/annotations-url-writer.test.ts lib/csv-list.test.ts lib/analytics-current-filters.test.ts", + "test": "tsx --test 'lib/**/*.test.ts'", + "test:watch": "tsx --test --watch 'lib/**/*.test.ts'", + "test:url": "tsx --test lib/annotations-url.test.ts lib/annotations-url-writer.test.ts lib/csv-list.test.ts lib/analytics-current-filters.test.ts", "export": "next build && next export", "deploy": "npm run build && touch out/.nojekyll && git add out/ && git commit -m 'Deploy to GitHub Pages' && git subtree push --prefix out origin gh-pages" }, @@ -89,6 +91,7 @@ "@types/react-dom": "^18", "postcss": "^8.5", "tailwindcss": "^4.1.9", + "tsx": "^4.23.12", "tw-animate-css": "1.3.3", "typescript": "^5" } diff --git a/server/helpers/constants.py b/server/helpers/constants.py index 1674323..a936d4e 100644 --- a/server/helpers/constants.py +++ b/server/helpers/constants.py @@ -8,6 +8,13 @@ class TsvFieldMeta(TypedDict): is_default: bool +# Canonical placeholder prefix used for GenomeAssembly.download_url before the real +# NCBI FTP path is resolved. Kept here (instead of jobs/services/assembly.py) so +# lightweight consumers (e.g. the TSV export) don't need to import that module's +# heavier dependencies (aiohttp, requests, ncbi client) just for this constant. +PLACEHOLDER_DOWNLOAD_URL_PREFIX = "http://localhost/annotrieve/pending/" + + # Frozen production default — do not remove, reorder, or rename keys. FIELD_TSV_MAP: dict[str, str] = { "annotation_id": "annotation_id", @@ -69,9 +76,22 @@ class TsvFieldMeta(TypedDict): "mapped_regions": "mapped_regions", } +# Assembly-derived columns. Unlike FIELD_TSV_EXTENDED_MAP, these paths resolve +# against the GenomeAssembly collection (joined on assembly_accession), not +# GenomeAnnotation, so they are kept in a separate map and require a dedicated +# resolver/join step (see helpers/tsv_fields.py). Column keys are prefixed with +# `assembly_` and reuse the exact GenomeAssembly field name to make the parent +# model they come from unambiguous. +FIELD_TSV_ASSEMBLY_MAP: dict[str, str] = { + "assembly_refseq_category": "refseq_category", + "assembly_download_url": "download_url", + "assembly_gc_percent": "assembly_stats__gc_percent", +} + FIELD_TSV_ALL_MAP: dict[str, str] = { **FIELD_TSV_MAP, **FIELD_TSV_EXTENDED_MAP, + **FIELD_TSV_ASSEMBLY_MAP, } TSV_FIELD_META: list[TsvFieldMeta] = [ @@ -122,6 +142,10 @@ class TsvFieldMeta(TypedDict): {"key": "pseudogene_gene_count", "label": "Pseudogene count", "group": "gene_stats", "is_default": False}, {"key": "pseudogene_gene_length_mean", "label": "Pseudogene length mean", "group": "gene_stats", "is_default": False}, {"key": "mapped_regions", "label": "Mapped regions (deprecated)", "group": "deprecated", "is_default": False}, + # Assembly (joined from the parent GenomeAssembly model) + {"key": "assembly_refseq_category", "label": "RefSeq category (reference genome)", "group": "assembly", "is_default": False}, + {"key": "assembly_download_url", "label": "Assembly download URL", "group": "assembly", "is_default": False}, + {"key": "assembly_gc_percent", "label": "Assembly GC content (%)", "group": "assembly", "is_default": False}, ] TSV_FIELD_GROUP_LABELS: dict[str, str] = { @@ -133,6 +157,7 @@ class TsvFieldMeta(TypedDict): "feature_summary": "Feature summary", "gene_stats": "Gene statistics", "deprecated": "Deprecated", + "assembly": "Assembly", } NO_VALUE_KEY = "no_value" diff --git a/server/jobs/services/assembly.py b/server/jobs/services/assembly.py index 1f3995b..cdfd86a 100644 --- a/server/jobs/services/assembly.py +++ b/server/jobs/services/assembly.py @@ -10,6 +10,7 @@ AssemblyStats, ) from helpers import assembly_sequence_files as seq_files +from helpers.constants import PLACEHOLDER_DOWNLOAD_URL_PREFIX from mongoengine import Q from clients import ncbi_datasets as ncbi_datasets_client from .classes import ( @@ -26,7 +27,6 @@ FTP_BASE = "https://ftp.ncbi.nlm.nih.gov/genomes/all" NCBI_FTP_SITE = "https://ftp.ncbi.nlm.nih.gov" -PLACEHOLDER_DOWNLOAD_URL_PREFIX = "http://localhost/annotrieve/pending/" REQUEST_TIMEOUT = 15 _FTP_REQUEST_COUNT = 0 _FTP_RATE_LIMIT_EVERY = 3 diff --git a/server/pytest.ini b/server/pytest.ini new file mode 100644 index 0000000..4802b40 --- /dev/null +++ b/server/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +pythonpath = . +markers = + unit: hermetic unit tests (no live Mongo/Celery/NFS) + integration: tests that need DB or external services diff --git a/server/requirements-dev.txt b/server/requirements-dev.txt new file mode 100644 index 0000000..415bdda --- /dev/null +++ b/server/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +pytest +httpx +mongomock +coverage diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 0000000..3065afb --- /dev/null +++ b/server/tests/conftest.py @@ -0,0 +1,61 @@ +"""Shared pytest fixtures for Annotrieve server tests.""" + +from __future__ import annotations + +import os +from typing import Iterator +from unittest.mock import MagicMock, patch + +import pytest + +# Settings reads env at import time; set hermetic defaults before importing main. +os.environ.setdefault("DB_NAME", "annotrieve_test") +os.environ.setdefault("DB_HOST", "localhost") +os.environ.setdefault("DB_PORT", "27017") +os.environ.setdefault("DB_USER", "test") +os.environ.setdefault("DB_PASS", "test") +os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/0") +os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/0") +os.environ.setdefault("LOCAL_ANNOTATIONS_DIR", "/tmp/annotrieve-test-annotations") +os.environ.setdefault("AUTH_KEY", "test-auth-key") +os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017") +os.environ.setdefault("IP_FINGERPRINT_SECRET", "test-ip-fingerprint-secret") + + +@pytest.fixture +def app(): + """FastAPI app with DB connect/disconnect patched (no live Mongo).""" + with ( + patch("main.connect_to_db"), + patch("main.close_db_connection"), + patch("main.create_celery", return_value=MagicMock()), + ): + from main import create_app + + yield create_app() + + +@pytest.fixture +def client(app) -> Iterator: + from fastapi.testclient import TestClient + + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def mock_celery_delay(): + """ + Factory: patch `.delay` on a Celery task object. + + Usage: + def test_x(mock_celery_delay): + with mock_celery_delay("jobs.taxonomy.export_flattened_taxonomy") as delay: + ... + delay.assert_called_once() + """ + + def _factory(task_path: str): + return patch(f"{task_path}.delay") + + return _factory diff --git a/server/tests/integration/.gitkeep b/server/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/integration/__init__.py b/server/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/integration/conftest.py b/server/tests/integration/conftest.py new file mode 100644 index 0000000..2dbdb4d --- /dev/null +++ b/server/tests/integration/conftest.py @@ -0,0 +1,158 @@ +"""Integration fixtures: mongomock DB, temp annotations dir, eager Celery.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Iterator +from unittest.mock import patch + +import pytest +from mongoengine import connect, disconnect + +# Override parent unit conftest redis defaults (setdefault would not win). +os.environ["DB_NAME"] = "annotrieve_integration" +os.environ["DB_HOST"] = "localhost" +os.environ["DB_PORT"] = "27017" +os.environ["DB_USER"] = "test" +os.environ["DB_PASS"] = "test" +os.environ["CELERY_BROKER_URL"] = "memory://" +os.environ["CELERY_RESULT_BACKEND"] = "cache+memory://" +os.environ["AUTH_KEY"] = "test-auth-key" +os.environ["IP_FINGERPRINT_SECRET"] = "test-ip-fingerprint-secret" +os.environ.setdefault( + "LOCAL_ANNOTATIONS_DIR", "/tmp/annotrieve-integration-annotations" +) + +# Settings may already be imported by the root conftest path — keep in sync. +try: + from configs.app_settings import settings as _settings + + _settings.CELERY_BROKER_URL = "memory://" + _settings.CELERY_RESULT_BACKEND = "cache+memory://" +except Exception: + pass + + +def _connect_mongomock() -> None: + """Replace real Mongo with in-process mongomock.""" + try: + disconnect(alias="default") + except Exception: + pass + connect(db=os.environ["DB_NAME"], host="mongomock://localhost", alias="default") + + +def _configure_eager_celery(celery_app) -> None: + celery_app.conf.task_always_eager = True + celery_app.conf.task_eager_propagates = True + celery_app.conf.task_store_eager_result = True + celery_app.conf.broker_url = "memory://" + celery_app.conf.result_backend = "cache+memory://" + + +def _clear_collections() -> None: + from db.models import ( + AnnotationError, + AnnotationSequenceMap, + BioProject, + GenomeAnnotation, + GenomeAssembly, + GenomicSequence, + Organism, + TaxonNode, + UploadRateLimit, + UsageRollup, + UserAnalytics, + ) + + for model in ( + GenomeAssembly, + Organism, + AnnotationSequenceMap, + GenomicSequence, + AnnotationError, + GenomeAnnotation, + TaxonNode, + BioProject, + UserAnalytics, + UsageRollup, + UploadRateLimit, + ): + try: + model.objects.delete() + except Exception: + pass + + +@pytest.fixture +def annotations_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Per-test LOCAL_ANNOTATIONS_DIR (disk artifacts + uploads).""" + root = tmp_path / "annotations" + root.mkdir() + monkeypatch.setenv("LOCAL_ANNOTATIONS_DIR", str(root)) + import importlib + + import helpers.assembly_sequence_files as seq_files + import helpers.file as file_helper + import jobs.taxonomy as tax_jobs + + imp = importlib.import_module("jobs.import_annotations") + + monkeypatch.setattr(seq_files, "ANNOTATIONS_PATH", str(root)) + monkeypatch.setattr(file_helper, "ANNOTATIONS_PATH", str(root)) + monkeypatch.setattr(tax_jobs, "ANNOTATIONS_PATH", str(root)) + monkeypatch.setattr(imp, "ANNOTATIONS_PATH", str(root)) + return root + + +@pytest.fixture +def app(annotations_dir: Path): + """FastAPI app with mongomock + eager Celery (real persistence, no Redis).""" + with patch("main.connect_to_db", side_effect=_connect_mongomock): + with patch("db.database.connect_to_db", side_effect=_connect_mongomock): + with patch( + "celery_app.celery_worker.connect_to_db", + side_effect=_connect_mongomock, + ): + from main import create_app + + application = create_app() + _configure_eager_celery(application.celery_app) + + from celery_app.celery_worker import app as worker_app + + _configure_eager_celery(worker_app) + + from jobs.taxonomy import export_flattened_taxonomy + from jobs.upload_gff import compute_custom_gff_stats + + for task in (compute_custom_gff_stats, export_flattened_taxonomy): + try: + task.app = worker_app + except Exception: + pass + if getattr(task, "app", None) is not None: + _configure_eager_celery(task.app) + + _connect_mongomock() + _clear_collections() + yield application + _clear_collections() + try: + disconnect(alias="default") + except Exception: + pass + + +@pytest.fixture +def client(app) -> Iterator: + from fastapi.testclient import TestClient + + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def auth_headers() -> dict[str, str]: + return {"X-Auth-Key": os.environ["AUTH_KEY"]} diff --git a/server/tests/integration/factories.py b/server/tests/integration/factories.py new file mode 100644 index 0000000..77dd6d7 --- /dev/null +++ b/server/tests/integration/factories.py @@ -0,0 +1,285 @@ +"""Minimal document + on-disk artifact builders for integration tests.""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +from db.embedded_documents import ( + AssemblyStats, + BuscoScore, + FeatureOverview, + GeneCategoryFeatureStats, + GenericLengthStats, + GenericTranscriptTypeStats, + AssociatedGenesStats, + GFFStats, + IndexedFileInfo, + SourceFileInfo, +) +from db.models import ( + BioProject, + GenomeAnnotation, + GenomeAssembly, + Organism, + TaxonNode, + UsageRollup, + UserAnalytics, +) +from helpers import assembly_sequence_files as seq_files + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def make_assembly( + *, + accession: str = "GCA_000001405.29", + paired: Optional[str] = None, + taxid: str = "9606", + organism_name: str = "Homo sapiens", + assembly_name: str = "GRCh38", + **kwargs: Any, +) -> GenomeAssembly: + defaults = dict( + assembly_accession=accession, + paired_assembly_accession=paired, + assembly_name=assembly_name, + taxid=taxid, + organism_name=organism_name, + taxon_lineage=[taxid, "9605", "1"], + source_database="GenBank", + assembly_level="chromosome", + download_url=f"https://example.com/ftp/{accession}", + refseq_category="reference genome", + assembly_stats=AssemblyStats(gc_percent=41), + ) + defaults.update(kwargs) + return GenomeAssembly(**defaults).save() + + +def make_annotation( + *, + annotation_id: Optional[str] = None, + assembly_accession: str = "GCA_000001405.29", + taxid: str = "9606", + organism_name: str = "Homo sapiens", + provider: str = "NCBI", + database: str = "RefSeq", + with_stats: bool = True, + with_busco: bool = True, + **kwargs: Any, +) -> GenomeAnnotation: + md5 = annotation_id or ("a" * 32) + rel_bgz = f"{taxid}/{assembly_accession}/{database.lower()}_{md5}.gff.gz" + source = SourceFileInfo( + database=database, + provider=provider, + release_date=_utcnow(), + url_path=f"https://example.com/{md5}.gff.gz", + last_modified=_utcnow(), + uncompressed_md5=md5, + ) + indexed = IndexedFileInfo( + bgzipped_path=rel_bgz, + csi_path=f"{rel_bgz}.csi", + uncompressed_md5=md5, + file_size=1024, + ) + summary = FeatureOverview( + types=["gene", "exon"], + sources=[database], + biotypes=["protein_coding"], + attribute_keys=["ID", "Parent"], + has_biotype=True, + has_cds=True, + has_exon=True, + ) + stats = None + if with_stats: + stats = GFFStats( + gene_category_stats={ + "coding": GeneCategoryFeatureStats( + total_count=100, + length_stats=GenericLengthStats(min=10, max=1000, mean=250.0), + ), + "non_coding": GeneCategoryFeatureStats( + total_count=20, + length_stats=GenericLengthStats(min=5, max=500, mean=100.0), + ), + }, + transcript_type_stats={ + "mRNA": GenericTranscriptTypeStats( + total_count=80, + length_stats=GenericLengthStats(min=10, max=900, mean=200.0), + associated_genes=AssociatedGenesStats(total_count=70), + ), + }, + ) + busco = None + if with_busco: + busco = BuscoScore( + busco_lineage="eukaryota_odb12", + busco_version="5.0.0", + total_count=255, + complete=90.0, + single_copy=80.0, + duplicated=10.0, + fragmented=5.0, + missing=5.0, + ) + doc = GenomeAnnotation( + annotation_id=md5, + assembly_accession=assembly_accession, + assembly_name=kwargs.pop("assembly_name", "GRCh38"), + organism_name=organism_name, + taxid=taxid, + taxon_lineage=[taxid, "9605", "1"], + source_file_info=source, + indexed_file_info=indexed, + features_summary=summary, + features_statistics=stats, + busco=busco, + **kwargs, + ) + return doc.save() + + +def make_taxon( + *, + taxid: str = "9606", + scientific_name: str = "Homo sapiens", + parent_id: str = "9605", + rank: str = "species", + children: Optional[list[str]] = None, + **kwargs: Any, +) -> TaxonNode: + return TaxonNode( + taxid=taxid, + scientific_name=scientific_name, + parent_id=parent_id, + rank=rank, + children=children or [], + annotations_count=kwargs.pop("annotations_count", 1), + assemblies_count=kwargs.pop("assemblies_count", 1), + organisms_count=kwargs.pop("organisms_count", 1), + **kwargs, + ).save() + + +def make_organism( + *, + taxid: str = "9606", + organism_name: str = "Homo sapiens", + **kwargs: Any, +) -> Organism: + return Organism( + taxid=taxid, + organism_name=organism_name, + common_name=kwargs.pop("common_name", "human"), + taxon_lineage=[taxid, "9605", "1"], + **kwargs, + ).save() + + +def make_bioproject( + *, + accession: str = "PRJNA123", + title: str = "Example project", + **kwargs: Any, +) -> BioProject: + return BioProject(accession=accession, title=title, **kwargs).save() + + +def make_user_analytics( + *, + fingerprint: str = "fp1", + country: str = "ES", + visits_count: int = 3, +) -> UserAnalytics: + now = _utcnow() + return UserAnalytics( + fingerprint=fingerprint, + country=country, + first_visit=now, + last_visit=now, + visits_count=visits_count, + ).save() + + +def make_usage_rollup() -> UsageRollup: + return UsageRollup( + key="latest", + as_of=_utcnow(), + by_capability={"list_annotations": 5}, + by_capability_requests={"list_annotations": 12}, + top_assemblies=[{"id": "GCA_1", "unique_users": 2}], + top_annotations=[], + top_taxons=[], + ).save() + + +def write_contigs_for_annotation(annotation: GenomeAnnotation, lines: Optional[list[str]] = None) -> Path: + """Write contigs.txt and a placeholder bgzipped GFF (get_contigs requires the gz path).""" + import helpers.file as file_helper + + rel = annotation.indexed_file_info.bgzipped_path + path = Path(seq_files.contigs_path_for_bgzipped(rel)) + path.parent.mkdir(parents=True, exist_ok=True) + content = "\n".join(lines or ["chr1", "chr2"]) + "\n" + path.write_text(content) + gff_path = Path(file_helper.get_annotation_file_path(annotation)) + gff_path.parent.mkdir(parents=True, exist_ok=True) + if not gff_path.exists(): + gff_path.write_bytes(b"") + return path + + +def write_assembly_sequence_files( + taxid: str, + accession: str, + *, + chromosomes: Optional[list[dict]] = None, + aliases_tsv: Optional[str] = None, +) -> tuple[Path, Path]: + chrom_path = Path(seq_files.chromosomes_path(taxid, accession)) + alias_path = Path(seq_files.chr_aliases_path(taxid, accession)) + chrom_path.parent.mkdir(parents=True, exist_ok=True) + chrom_path.write_text( + json.dumps( + chromosomes + or [ + { + "chr_name": "1", + "sequence_name": "chr1", + "length": 1000, + "sequence_role": "assembled-molecule", + } + ] + ) + ) + alias_path.write_text(aliases_tsv or "chr1\t1\nchr2\t2\n") + return chrom_path, alias_path + + +def write_prebuilt_flattened_tree(fmt: str = "json") -> Path: + from helpers.flattened_taxonomy_export import get_flattened_tree_file_path + + path = Path(get_flattened_tree_file_path(fmt)) + path.parent.mkdir(parents=True, exist_ok=True) + if fmt == "tsv": + path.write_text("taxid\tparent_taxid\tscientific_name\n9606\t9605\tHomo sapiens\n") + else: + path.write_text( + json.dumps( + { + "fields": ["taxid", "parent_taxid", "scientific_name"], + "rows": [["9606", "9605", "Homo sapiens"]], + } + ) + ) + return path diff --git a/server/tests/integration/test_analytics.py b/server/tests/integration/test_analytics.py new file mode 100644 index 0000000..63c9dc7 --- /dev/null +++ b/server/tests/integration/test_analytics.py @@ -0,0 +1,32 @@ +import pytest + +from tests.integration.factories import make_usage_rollup, make_user_analytics + +pytestmark = pytest.mark.integration + + +class TestAnalytics: + def test_summary(self, client): + make_user_analytics(fingerprint="fp1", country="ES", visits_count=3) + make_user_analytics(fingerprint="fp2", country="US", visits_count=1) + resp = client.get("/analytics/summary") + assert resp.status_code == 200 + body = resp.json() + assert body["unique_users"] == 2 + assert body["countries"] == 2 + + def test_top_countries(self, client): + make_user_analytics(fingerprint="fp1", country="ES", visits_count=5) + make_user_analytics(fingerprint="fp2", country="ES", visits_count=2) + make_user_analytics(fingerprint="fp3", country="US", visits_count=1) + resp = client.get("/analytics/top-countries", params={"limit": 5}) + assert resp.status_code == 200 + rows = resp.json() + assert isinstance(rows, list) + assert rows[0]["country"] == "ES" + assert rows[0]["unique_users"] == 2 + + def test_capabilities_with_rollup(self, client): + make_usage_rollup() + resp = client.get("/analytics/capabilities") + assert resp.status_code == 200 diff --git a/server/tests/integration/test_annotation_detail.py b/server/tests/integration/test_annotation_detail.py new file mode 100644 index 0000000..525bd5b --- /dev/null +++ b/server/tests/integration/test_annotation_detail.py @@ -0,0 +1,27 @@ +import pytest + +from tests.integration.factories import make_annotation, write_contigs_for_annotation + +pytestmark = pytest.mark.integration + + +class TestAnnotationDetail: + def test_get_metadata(self, client): + ann = make_annotation(annotation_id="h" * 32) + resp = client.get(f"/annotations/{ann.annotation_id}") + assert resp.status_code == 200 + body = resp.json() + assert body["annotation_id"] == ann.annotation_id + assert body["taxid"] == "9606" + + def test_get_404(self, client): + resp = client.get(f"/annotations/{'z' * 32}") + assert resp.status_code == 404 + + def test_contigs_stream(self, client): + ann = make_annotation(annotation_id="i" * 32) + write_contigs_for_annotation(ann, ["chr1", "chrX"]) + resp = client.get(f"/annotations/{ann.annotation_id}/contigs") + assert resp.status_code == 200 + assert "chr1" in resp.text + assert "chrX" in resp.text diff --git a/server/tests/integration/test_annotation_stats.py b/server/tests/integration/test_annotation_stats.py new file mode 100644 index 0000000..1dab982 --- /dev/null +++ b/server/tests/integration/test_annotation_stats.py @@ -0,0 +1,34 @@ +import pytest + +from tests.integration.factories import make_annotation + +pytestmark = pytest.mark.integration + + +class TestAnnotationStats: + def test_gene_stats_summary(self, client): + make_annotation(annotation_id="j" * 32, with_stats=True) + resp = client.get("/annotations/gene-stats") + assert resp.status_code == 200 + body = resp.json() + assert body["total_annotations"] == 1 + assert "coding" in body["summary"]["genes"] + assert body["summary"]["genes"]["coding"]["average_count"] == 100.0 + + def test_busco_stats_summary(self, client): + make_annotation(annotation_id="k" * 32, with_busco=True) + resp = client.get("/annotations/busco-stats") + assert resp.status_code == 200 + body = resp.json() + assert body["total_annotations"] == 1 + assert body["summary"]["complete"]["mean"] == 90.0 + + def test_transcript_stats_empty_smoke(self, client): + # mongomock lacks $reduce used when transcript_type_stats are present; + # empty queryset still exercises the HTTP → service path. + resp = client.get("/annotations/transcript-stats") + assert resp.status_code == 200 + body = resp.json() + assert body["total_annotations"] == 0 + assert body["summary"]["types"] == {} + assert "metrics" in body diff --git a/server/tests/integration/test_annotations_list.py b/server/tests/integration/test_annotations_list.py new file mode 100644 index 0000000..0704663 --- /dev/null +++ b/server/tests/integration/test_annotations_list.py @@ -0,0 +1,43 @@ +import pytest + +from tests.integration.factories import make_annotation + +pytestmark = pytest.mark.integration + + +class TestAnnotationsList: + def test_empty_list(self, client): + resp = client.get("/annotations") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 0 + assert body["results"] == [] + assert body["limit"] == 20 + assert body["offset"] == 0 + + def test_seeded_list_and_filter(self, client): + make_annotation(annotation_id="c" * 32, provider="NCBI", taxid="9606") + make_annotation( + annotation_id="d" * 32, + provider="Ensembl", + taxid="10090", + assembly_accession="GCA_000001635.9", + organism_name="Mus musculus", + ) + + resp = client.get("/annotations", params={"limit": 10, "offset": 0}) + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 2 + assert len(body["results"]) == 2 + + filtered = client.get("/annotations", params={"taxids": "9606"}) + assert filtered.status_code == 200 + assert filtered.json()["total"] == 1 + assert filtered.json()["results"][0]["taxid"] == "9606" + + def test_post_list(self, client): + make_annotation(annotation_id="e" * 32) + resp = client.post("/annotations", json={"limit": 5}) + assert resp.status_code == 200 + assert resp.json()["total"] == 1 diff --git a/server/tests/integration/test_annotations_report.py b/server/tests/integration/test_annotations_report.py new file mode 100644 index 0000000..2ac1be6 --- /dev/null +++ b/server/tests/integration/test_annotations_report.py @@ -0,0 +1,41 @@ +import pytest + +from tests.integration.factories import make_annotation, make_assembly + +pytestmark = pytest.mark.integration + + +class TestAnnotationsReport: + def test_default_tsv_header(self, client): + make_annotation(annotation_id="f" * 32) + resp = client.get("/annotations/report") + assert resp.status_code == 200 + assert "text/tab-separated-values" in resp.headers["content-type"] + text = resp.text + header = text.splitlines()[0] + assert "annotation_id" in header + assert "assembly_accession" in header + assert ("f" * 32) in text + + def test_selected_fields_with_assembly_join(self, client): + make_assembly(accession="GCA_000001405.29", refseq_category="reference genome") + make_annotation(annotation_id="g" * 32, assembly_accession="GCA_000001405.29") + # selected_fields may only list extended columns (defaults always included). + resp = client.post( + "/annotations/report", + json={ + "selected_fields": [ + "assembly_refseq_category", + "assembly_download_url", + ] + }, + ) + assert resp.status_code == 200, resp.text + lines = resp.text.splitlines() + header = lines[0].split("\t") + assert "annotation_id" in header # default column + assert "assembly_refseq_category" in header + assert "assembly_download_url" in header + row = lines[1].split("\t") + assert "reference genome" in row + assert any("example.com" in cell for cell in row) diff --git a/server/tests/integration/test_assemblies.py b/server/tests/integration/test_assemblies.py new file mode 100644 index 0000000..0151174 --- /dev/null +++ b/server/tests/integration/test_assemblies.py @@ -0,0 +1,52 @@ +import pytest + +from tests.integration.factories import ( + make_assembly, + write_assembly_sequence_files, +) + +pytestmark = pytest.mark.integration + + +class TestAssemblies: + def test_list_and_detail(self, client): + make_assembly(accession="GCA_000001405.29") + resp = client.get("/assemblies") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + + detail = client.get("/assemblies/GCA_000001405.29") + assert detail.status_code == 200 + assert detail.json()["assembly_accession"] == "GCA_000001405.29" + + def test_detail_404(self, client): + assert client.get("/assemblies/GCA_MISSING").status_code == 404 + + def test_paired(self, client): + make_assembly( + accession="GCA_000001405.29", + paired="GCF_000001405.40", + ) + make_assembly( + accession="GCF_000001405.40", + paired="GCA_000001405.29", + download_url="https://example.com/ftp/GCF_000001405.40", + ) + resp = client.get("/assemblies/GCA_000001405.29/paired") + assert resp.status_code == 200 + assert resp.json()["assembly_accession"] == "GCF_000001405.40" + + def test_chromosomes_and_aliases(self, client): + make_assembly(accession="GCA_000001405.29", taxid="9606") + write_assembly_sequence_files("9606", "GCA_000001405.29") + chrom = client.get("/assemblies/GCA_000001405.29/assembled-molecules") + assert chrom.status_code == 200 + aliases = client.get("/assemblies/GCA_000001405.29/chr-aliases") + assert aliases.status_code == 200 + assert "chr1" in aliases.text + + def test_chromosomes_missing_404(self, client): + make_assembly(accession="GCA_000001405.29", taxid="9606") + resp = client.get("/assemblies/GCA_000001405.29/assembled-molecules") + assert resp.status_code == 404 diff --git a/server/tests/integration/test_jobs_auth.py b/server/tests/integration/test_jobs_auth.py new file mode 100644 index 0000000..1b6d37c --- /dev/null +++ b/server/tests/integration/test_jobs_auth.py @@ -0,0 +1,28 @@ +from unittest.mock import MagicMock, patch + +import pytest + +pytestmark = pytest.mark.integration + + +class TestJobsAuth: + def test_missing_key_401(self, client): + resp = client.post( + "/jobs/update/taxonomy/export-flattened", + headers={"X-Auth-Key": "wrong-key"}, + ) + assert resp.status_code == 401 + + def test_good_key_triggers_delay(self, client, auth_headers): + mock_delay = MagicMock() + with patch( + "services.jobs_service.export_flattened_taxonomy", + ) as task: + task.delay = mock_delay + resp = client.post( + "/jobs/update/taxonomy/export-flattened", + headers=auth_headers, + ) + assert resp.status_code == 200, resp.text + mock_delay.assert_called_once() + assert "message" in resp.json() diff --git a/server/tests/integration/test_organisms_bioprojects.py b/server/tests/integration/test_organisms_bioprojects.py new file mode 100644 index 0000000..331a7cf --- /dev/null +++ b/server/tests/integration/test_organisms_bioprojects.py @@ -0,0 +1,31 @@ +import pytest + +from tests.integration.factories import make_bioproject, make_organism + +pytestmark = pytest.mark.integration + + +class TestOrganismsBioprojects: + def test_organisms(self, client): + make_organism(taxid="9606", organism_name="Homo sapiens") + listing = client.get("/organisms") + assert listing.status_code == 200 + assert listing.json()["total"] == 1 + + detail = client.get("/organisms/9606") + assert detail.status_code == 200 + assert detail.json()["organism_name"] == "Homo sapiens" + + assert client.get("/organisms/0000").status_code == 404 + + def test_bioprojects(self, client): + make_bioproject(accession="PRJNA999", title="Test project") + listing = client.get("/bioprojects") + assert listing.status_code == 200 + assert listing.json()["total"] == 1 + + detail = client.get("/bioprojects/PRJNA999") + assert detail.status_code == 200 + assert detail.json()["title"] == "Test project" + + assert client.get("/bioprojects/PRJNA000").status_code == 404 diff --git a/server/tests/integration/test_taxonomy_flattened.py b/server/tests/integration/test_taxonomy_flattened.py new file mode 100644 index 0000000..3998fb4 --- /dev/null +++ b/server/tests/integration/test_taxonomy_flattened.py @@ -0,0 +1,29 @@ +import pytest + +from tests.integration.factories import make_taxon, write_prebuilt_flattened_tree + +pytestmark = pytest.mark.integration + + +class TestTaxonomyFlattened: + def test_json_fallback_without_prebuilt(self, client): + make_taxon(taxid="9606", scientific_name="Homo sapiens", parent_id="9605") + make_taxon(taxid="9605", scientific_name="Homo", parent_id="1", children=["9606"]) + resp = client.get("/taxons/flattened-tree", params={"format": "json"}) + assert resp.status_code == 200 + body = resp.json() + assert "fields" in body + assert "rows" in body + assert any("9606" in (row if isinstance(row, list) else [row]) for row in body["rows"]) or any( + "Homo sapiens" in str(row) for row in body["rows"] + ) + + def test_prebuilt_redirect(self, client): + write_prebuilt_flattened_tree("json") + resp = client.get( + "/taxons/flattened-tree", + params={"format": "json"}, + follow_redirects=False, + ) + assert resp.status_code == 307 + assert "flattened-tree.json" in resp.headers.get("location", "") diff --git a/server/tests/integration/test_upload_gff.py b/server/tests/integration/test_upload_gff.py new file mode 100644 index 0000000..8138569 --- /dev/null +++ b/server/tests/integration/test_upload_gff.py @@ -0,0 +1,89 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from jobs.upload_gff import compute_custom_gff_stats + +pytestmark = pytest.mark.integration + +TINY_GFF = ( + "chr1\tRefSeq\tgene\t1\t100\t.\t+\t.\tID=g1;biotype=protein_coding\n" + "chr1\tRefSeq\tmRNA\t1\t100\t.\t+\t.\tID=t1;Parent=g1\n" + "chr1\tRefSeq\texon\t1\t100\t.\t+\t.\tID=e1;Parent=t1\n" + "chr1\tRefSeq\tCDS\t1\t99\t.\t+\t0\tID=c1;Parent=t1\n" +) + + +class TestUploadGff: + def test_rate_limit_endpoint(self, client): + resp = client.get("/annotations/upload-gff/rate-limit") + assert resp.status_code == 200 + body = resp.json() + assert "used" in body + assert "remaining" in body + assert body["used"] == 0 + + def test_upload_and_poll_success(self, client, annotations_dir): + def fake_sort(inp, out): + Path(out).write_text(Path(inp).read_text()) + + stored: dict[str, dict] = {} + + def fake_delay(upload_uuid, filename, custom_name): + self_mock = MagicMock() + result = compute_custom_gff_stats.run.__func__( + self_mock, upload_uuid, filename, custom_name + ) + task_id = "eager-upload-task" + stored[task_id] = result + async_result = MagicMock() + async_result.id = task_id + return async_result + + class FakeAsyncResult: + def __init__(self, task_id, app=None): + self.task_id = task_id + self._result = stored.get(task_id) + + def ready(self): + return self.task_id in stored + + def successful(self): + return self.task_id in stored + + @property + def result(self): + return self._result + + @property + def traceback(self): + return None + + with ( + patch( + "jobs.upload_gff.annotation_service.sort_gff_file", + side_effect=fake_sort, + ), + patch("services.upload_gff_service.compute_custom_gff_stats") as task_mod, + patch("api.annotations.AsyncResult", FakeAsyncResult), + ): + task_mod.delay = fake_delay + files = {"file": ("tiny.gff", TINY_GFF.encode(), "text/plain")} + data = {"custom_name": "My custom GFF"} + resp = client.post("/annotations/upload-gff", files=files, data=data) + + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["task_id"] == "eager-upload-task" + assert "remaining_quota" in payload + + status = client.get( + f"/annotations/upload-gff/jobs/{payload['task_id']}" + ) + assert status.status_code == 200 + body = status.json() + assert body["state"] == "SUCCESS" + assert body["result"]["is_custom"] is True + assert body["result"]["custom_name"] == "My custom GFF" + assert body["result"]["annotation_id"] diff --git a/server/tests/test_tsv_fields.py b/server/tests/test_tsv_fields.py deleted file mode 100644 index cebcac9..0000000 --- a/server/tests/test_tsv_fields.py +++ /dev/null @@ -1,120 +0,0 @@ -import json -import unittest -from datetime import date, datetime - -from fastapi import HTTPException - -from helpers import constants as constants_helper -from helpers import tsv_fields as tsv_fields_helper - - -class ResolveTsvFieldMapTests(unittest.TestCase): - def test_none_returns_frozen_default_map(self): - result = tsv_fields_helper.resolve_tsv_field_map(None) - self.assertEqual(result, constants_helper.FIELD_TSV_MAP) - self.assertEqual(list(result.keys()), list(constants_helper.FIELD_TSV_MAP.keys())) - - def test_empty_string_returns_default_map(self): - result = tsv_fields_helper.resolve_tsv_field_map("") - self.assertEqual(result, constants_helper.FIELD_TSV_MAP) - - def test_appends_extended_fields_in_definition_order(self): - result = tsv_fields_helper.resolve_tsv_field_map("busco_complete,taxon_lineage") - default_keys = list(constants_helper.FIELD_TSV_MAP.keys()) - self.assertEqual(list(result.keys())[: len(default_keys)], default_keys) - self.assertIn("taxon_lineage", result) - self.assertIn("busco_complete", result) - taxon_index = list(result.keys()).index("taxon_lineage") - busco_index = list(result.keys()).index("busco_complete") - self.assertLess(taxon_index, busco_index) - - def test_rejects_unknown_field(self): - with self.assertRaises(HTTPException) as ctx: - tsv_fields_helper.resolve_tsv_field_map("not_a_real_field") - self.assertEqual(ctx.exception.status_code, 400) - - def test_rejects_default_field_in_selected_fields(self): - with self.assertRaises(HTTPException) as ctx: - tsv_fields_helper.resolve_tsv_field_map("annotation_id,release_date") - self.assertEqual(ctx.exception.status_code, 400) - self.assertIn("annotation_id", ctx.exception.detail) - - def test_ignores_duplicate_tokens(self): - result = tsv_fields_helper.resolve_tsv_field_map("release_date,release_date") - self.assertEqual(list(result.keys()).count("release_date"), 1) - - -class DigMongoValueTests(unittest.TestCase): - def test_top_level_present(self): - self.assertEqual( - tsv_fields_helper.dig_mongo_value({"annotation_id": "abc"}, "annotation_id"), - "abc", - ) - - def test_top_level_missing(self): - self.assertIsNone(tsv_fields_helper.dig_mongo_value({}, "annotation_id")) - - def test_nested_missing_middle_key(self): - doc = {"source_file_info": {"database": "GenBank"}} - self.assertIsNone( - tsv_fields_helper.dig_mongo_value(doc, "source_file_info__pipeline__name") - ) - - def test_nested_none_middle(self): - doc = {"source_file_info": {"pipeline": None}} - self.assertIsNone( - tsv_fields_helper.dig_mongo_value(doc, "source_file_info__pipeline__name") - ) - - def test_nested_fully_present(self): - doc = {"source_file_info": {"pipeline": {"name": "BRAKER3"}}} - self.assertEqual( - tsv_fields_helper.dig_mongo_value(doc, "source_file_info__pipeline__name"), - "BRAKER3", - ) - - def test_list_and_dict_leaves_pass_through(self): - doc = { - "taxon_lineage": ["9606", "9605"], - "features_summary": {"root_type_counts": {"gene": 3}}, - } - self.assertEqual( - tsv_fields_helper.dig_mongo_value(doc, "taxon_lineage"), - ["9606", "9605"], - ) - self.assertEqual( - tsv_fields_helper.dig_mongo_value( - doc, "features_summary__root_type_counts" - ), - {"gene": 3}, - ) - - -class FormatTsvCellTests(unittest.TestCase): - def test_default_path_matches_str_behavior(self): - self.assertEqual(tsv_fields_helper.format_tsv_cell(None), "") - self.assertEqual(tsv_fields_helper.format_tsv_cell("value"), "value") - self.assertEqual(tsv_fields_helper.format_tsv_cell(42), "42") - - def test_extended_path_formats_complex_values(self): - self.assertEqual(tsv_fields_helper.format_tsv_cell(True, extended=True), "true") - self.assertEqual(tsv_fields_helper.format_tsv_cell(False, extended=True), "false") - self.assertEqual( - tsv_fields_helper.format_tsv_cell(["gene", "exon"], extended=True), - "gene;exon", - ) - self.assertEqual( - tsv_fields_helper.format_tsv_cell({"gene": 3}, extended=True), - json.dumps({"gene": 3}, separators=(",", ":")), - ) - dt = datetime(2024, 5, 1, 12, 30, 0) - self.assertEqual(tsv_fields_helper.format_tsv_cell(dt, extended=True), dt.isoformat()) - self.assertEqual( - tsv_fields_helper.format_tsv_cell(date(2024, 5, 1), extended=True), - "2024-05-01", - ) - self.assertEqual(tsv_fields_helper.format_tsv_cell(None, extended=True), "") - - -if __name__ == "__main__": - unittest.main() diff --git a/server/tests/unit/__init__.py b/server/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/unit/fakes.py b/server/tests/unit/fakes.py new file mode 100644 index 0000000..ee45de4 --- /dev/null +++ b/server/tests/unit/fakes.py @@ -0,0 +1,91 @@ +"""Shared fakes for hermetic server unit tests.""" + +from __future__ import annotations + +from typing import Any, Iterable, Iterator, List, Optional + + +class FakeQuerySet: + """ + Lightweight stand-in for mongoengine QuerySets. + + Supports count/aggregate/filter/order_by/exclude/skip/limit/as_pymongo/first/slicing. + """ + + def __init__( + self, + items: Optional[Iterable[Any]] = None, + *, + total: Optional[int] = None, + aggregate_docs: Optional[List[Any]] = None, + aggregate_sequence: Optional[List[List[Any]]] = None, + ): + self._items = list(items or []) + self._total = total if total is not None else len(self._items) + self._aggregate_docs = list(aggregate_docs or []) + self._aggregate_sequence = ( + list(aggregate_sequence) if aggregate_sequence is not None else None + ) + self._call = 0 + self._skip = 0 + self._limit: Optional[int] = None + + def count(self) -> int: + return self._total + + def aggregate(self, _pipeline=None) -> List[Any]: + if self._aggregate_sequence is not None: + if self._call >= len(self._aggregate_sequence): + docs: List[Any] = [] + else: + docs = self._aggregate_sequence[self._call] + self._call += 1 + return list(docs) + return list(self._aggregate_docs) + + def filter(self, *args, **kwargs) -> "FakeQuerySet": + return self + + def order_by(self, *args, **kwargs) -> "FakeQuerySet": + return self + + def exclude(self, *args, **kwargs) -> "FakeQuerySet": + return self + + def only(self, *args, **kwargs) -> "FakeQuerySet": + return self + + def skip(self, n: int) -> "FakeQuerySet": + self._skip = int(n) + return self + + def limit(self, n: int) -> "FakeQuerySet": + self._limit = int(n) + return self + + def as_pymongo(self) -> List[Any]: + end = None if self._limit is None else self._skip + self._limit + sliced = self._items[self._skip : end] + out = [] + for item in sliced: + if isinstance(item, dict): + out.append(item) + elif hasattr(item, "to_mongo"): + out.append(item.to_mongo().to_dict()) + else: + out.append(item) + return out + + def first(self) -> Any: + return self._items[0] if self._items else None + + def __iter__(self) -> Iterator[Any]: + return iter(self._items) + + def __getitem__(self, key): + if isinstance(key, slice): + return self._items[key] + return self._items[key] + + def __len__(self) -> int: + return len(self._items) diff --git a/server/tests/unit/helpers/__init__.py b/server/tests/unit/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/unit/helpers/test_busco_stats.py b/server/tests/unit/helpers/test_busco_stats.py new file mode 100644 index 0000000..fb90140 --- /dev/null +++ b/server/tests/unit/helpers/test_busco_stats.py @@ -0,0 +1,73 @@ +import pytest +from fastapi import HTTPException + +from helpers import busco_stats as busco_stats_helper + +pytestmark = pytest.mark.unit + + +class FakeQuerySet: + def __init__(self, total=5, aggregate_docs=None): + self._total = total + self._aggregate_docs = list(aggregate_docs or []) + + def count(self): + return self._total + + def aggregate(self, _pipeline): + return list(self._aggregate_docs) + + +class TestBuscoStats: + def test_invalid_metric_raises_400(self): + with pytest.raises(HTTPException) as ctx: + busco_stats_helper.get_busco_metric_values("not_a_metric", FakeQuerySet()) + assert ctx.value.status_code == 400 + + def test_summary_empty_aggregate(self): + result = busco_stats_helper.get_busco_stats_summary(FakeQuerySet(total=3, aggregate_docs=[])) + assert result["total_annotations"] == 3 + assert result["metrics"] == busco_stats_helper.BUSCO_METRICS + for metric in busco_stats_helper.BUSCO_METRICS: + assert result["summary"][metric]["mean"] is None + assert result["summary"][metric]["annotations_count"] == 0 + assert result["summary"][metric]["missing_annotations_count"] == 3 + + def test_summary_populated_aggregate(self): + docs = [ + { + "count": 2, + "complete_avg": 95.555, + "single_copy_avg": 90.0, + "duplicated_avg": 5.0, + "fragmented_avg": 2.0, + "missing_avg": 1.0, + } + ] + result = busco_stats_helper.get_busco_stats_summary( + FakeQuerySet(total=4, aggregate_docs=docs) + ) + assert result["summary"]["complete"]["mean"] == 95.56 + assert result["summary"]["complete"]["annotations_count"] == 2 + assert result["summary"]["complete"]["missing_annotations_count"] == 2 + + def test_metric_values_delegates_to_response_helper(self, monkeypatch): + called = {} + + def fake_response(annotations, field_path, include_annotations, **extra): + called["field_path"] = field_path + called["include"] = include_annotations + called["extra"] = extra + return {"values": [1.0], **extra} + + monkeypatch.setattr( + "helpers.busco_stats.response_helper.metric_values_response", + fake_response, + ) + out = busco_stats_helper.get_busco_metric_values( + "complete", FakeQuerySet(), include_annotations=True + ) + assert called["field_path"] == "busco.complete" + assert called["include"] is True + assert out["values"] == [1.0] + assert out["metric"] == "complete" diff --git a/server/tests/unit/helpers/test_feature_stats.py b/server/tests/unit/helpers/test_feature_stats.py new file mode 100644 index 0000000..b61e666 --- /dev/null +++ b/server/tests/unit/helpers/test_feature_stats.py @@ -0,0 +1,132 @@ +import pytest +from fastapi import HTTPException + +from helpers import feature_stats as feature_stats_helper + +pytestmark = pytest.mark.unit + + +class FakeQuerySet: + """Queryable stand-in with configurable aggregate payloads per call.""" + + def __init__(self, total=10, aggregate_sequence=None, default_docs=None): + self._total = total + self._aggregate_sequence = list(aggregate_sequence) if aggregate_sequence is not None else None + self._default_docs = list(default_docs or []) + self._call = 0 + + def count(self): + return self._total + + def aggregate(self, _pipeline): + if self._aggregate_sequence is not None: + if self._call >= len(self._aggregate_sequence): + docs = [] + else: + docs = self._aggregate_sequence[self._call] + self._call += 1 + return list(docs) + return list(self._default_docs) + + +class TestGeneStats: + def test_invalid_metric_raises_400(self): + with pytest.raises(HTTPException) as ctx: + feature_stats_helper.get_gene_category_metric_values( + "coding", "bad_metric", FakeQuerySet() + ) + assert ctx.value.status_code == 400 + + def test_summary_happy_path(self): + # For each of 3 categories, first key attempt returns one annotation row. + per_category = [{"total_count": 10, "mean_length": 100.0}] + qs = FakeQuerySet( + total=5, + aggregate_sequence=[per_category, per_category, per_category], + ) + result = feature_stats_helper.get_gene_stats_summary(qs) + assert result["total_annotations"] == 5 + assert result["categories"] == ["coding", "non_coding", "pseudogene"] + coding = result["summary"]["genes"]["coding"] + assert coding["annotations_count"] == 1 + assert coding["average_count"] == 10.0 + assert coding["average_mean_length"] == 100.0 + assert coding["missing_annotations_count"] == 4 + + def test_category_details_not_found(self): + qs = FakeQuerySet(total=2, default_docs=[]) + with pytest.raises(HTTPException) as ctx: + feature_stats_helper.get_gene_category_details("coding", qs) + assert ctx.value.status_code == 404 + + def test_category_details_happy_path(self): + # First aggregates for key discovery (coding, then coding_genes), then values. + discovery = [{"_id": 1}] + values = [ + { + "category_data": { + "total_count": 4, + "length_stats": {"mean": 50.0}, + } + }, + { + "category_data": { + "total_count": 6, + "length_stats": {"mean": 70.0}, + } + }, + ] + qs = FakeQuerySet( + total=3, + aggregate_sequence=[discovery, values], + ) + result = feature_stats_helper.get_gene_category_details("coding", qs) + assert result["category"] == "coding" + assert result["annotations_count"] == 2 + assert result["summary"]["total_count"]["mean"] == 5.0 + assert result["summary"]["average_mean_length"]["mean"] == 60.0 + + +class TestTranscriptStats: + def test_summary_happy_path(self): + docs = [ + { + "type": "mRNA", + "annotations_count": 2, + "total_count_sum": 20, + "mean_length_sum": 200, + "mean_length_count": 2, + "has_cds_stats": True, + } + ] + result = feature_stats_helper.get_transcript_stats_summary( + FakeQuerySet(total=4, default_docs=docs) + ) + assert result["total_annotations"] == 4 + assert "mRNA" in result["types"] + assert result["summary"]["types"]["mRNA"]["average_count"] == 10.0 + assert "cds_total_count" in result["metrics"] + + def test_type_details_not_found(self): + with pytest.raises(HTTPException) as ctx: + feature_stats_helper.get_transcript_type_details( + "mRNA", FakeQuerySet(total=1, default_docs=[]) + ) + assert ctx.value.status_code == 404 + + def test_metric_values_invalid_for_type(self): + discovery = [{"_id": 1}] + values = [ + { + "type_data": { + "total_count": 3, + "length_stats": {"mean": 10.0}, + } + } + ] + qs = FakeQuerySet(total=1, aggregate_sequence=[discovery, values]) + with pytest.raises(HTTPException) as ctx: + feature_stats_helper.get_transcript_type_metric_values( + "mRNA", "cds_total_count", qs + ) + assert ctx.value.status_code == 400 diff --git a/server/tests/unit/helpers/test_flattened_taxonomy_export.py b/server/tests/unit/helpers/test_flattened_taxonomy_export.py new file mode 100644 index 0000000..1125819 --- /dev/null +++ b/server/tests/unit/helpers/test_flattened_taxonomy_export.py @@ -0,0 +1,81 @@ +import os +from unittest.mock import patch + +import pytest + +from helpers import flattened_taxonomy_export as flat + +pytestmark = pytest.mark.unit + + +class TestFlattenedTaxonomyExportHelpers: + def test_fields_non_empty_and_stable_prefix(self): + assert len(flat.FLATTENED_TREE_FIELDS) >= 10 + assert flat.FLATTENED_TREE_FIELDS[:3] == [ + "taxid", + "parent_taxid", + "scientific_name", + ] + + def test_file_path_uses_base_dir(self): + path = flat.get_flattened_tree_file_path("tsv", base_dir="/tmp/ann") + assert path == "/tmp/ann/taxonomy/flattened-tree.tsv" + path_json = flat.get_flattened_tree_file_path("json", base_dir="/tmp/ann") + assert path_json.endswith("flattened-tree.json") + + def test_public_url_default_and_override(self): + assert ( + flat.get_flattened_tree_public_url("tsv") + == "/annotrieve/files/taxonomy/flattened-tree.tsv" + ) + with patch.dict(os.environ, {"PUBLIC_FILES_BASE": "/files"}): + assert ( + flat.get_flattened_tree_public_url("json") + == "/files/taxonomy/flattened-tree.json" + ) + + def test_stats_mean_missing_returns_zero(self): + assert flat._stats_mean({}, "genes", "coding", "count", "mean") == 0.0 + assert flat._stats_mean({"stats": None}, "busco", "missing", "mean") == 0.0 + + def test_stats_mean_nested(self): + doc = {"stats": {"genes": {"coding": {"count": {"mean": 12.5}}}}} + assert flat._stats_mean(doc, "genes", "coding", "count", "mean") == 12.5 + + def test_doc_to_json_row_and_tsv_alignment(self): + doc = { + "taxid": "9606", + "parent_id": "9605", + "scientific_name": "Homo sapiens", + "annotations_count": 2, + "assemblies_count": 1, + "organisms_count": 1, + "rank": "species", + "stats": { + "genes": {"coding": {"count": {"mean": 3}}}, + "busco": {"single_copy": {"mean": 90}}, + }, + } + row = flat.doc_to_json_row(doc) + assert len(row) == len(flat.FLATTENED_TREE_FIELDS) + assert row[0] == "9606" + assert row[1] == "9605" + assert row[2] == "Homo sapiens" + assert row[7] == 3.0 + line = flat.doc_to_tsv_line(doc) + cols = line.rstrip("\n").split("\t") + assert len(cols) == len(flat.FLATTENED_TREE_FIELDS) + assert cols[0] == "9606" + assert cols[1] == "9605" + + def test_empty_parent_in_tsv(self): + doc = { + "taxid": "2759", + "parent_id": None, + "scientific_name": "Eukaryota", + "rank": "superkingdom", + } + row = flat.doc_to_json_row(doc) + assert row[1] is None + cols = flat.doc_to_tsv_line(doc).rstrip("\n").split("\t") + assert cols[1] == "" diff --git a/server/tests/test_parameters.py b/server/tests/unit/helpers/test_parameters.py similarity index 99% rename from server/tests/test_parameters.py rename to server/tests/unit/helpers/test_parameters.py index 0502d2b..b2b2ea8 100644 --- a/server/tests/test_parameters.py +++ b/server/tests/unit/helpers/test_parameters.py @@ -1,8 +1,12 @@ import unittest +import pytest + from helpers import parameters as parameters_helper from helpers import annotation as annotation_helper +pytestmark = pytest.mark.unit + class SplitStringParamTests(unittest.TestCase): def test_simple_multi(self): diff --git a/server/tests/unit/helpers/test_tsv_fields.py b/server/tests/unit/helpers/test_tsv_fields.py new file mode 100644 index 0000000..b909a30 --- /dev/null +++ b/server/tests/unit/helpers/test_tsv_fields.py @@ -0,0 +1,259 @@ +import json +import unittest +from datetime import date, datetime +from unittest.mock import patch + +import pytest +from fastapi import HTTPException + +from helpers import constants as constants_helper +from helpers import tsv_fields as tsv_fields_helper + +pytestmark = pytest.mark.unit + + +class ResolveTsvFieldMapTests(unittest.TestCase): + def test_none_returns_frozen_default_map(self): + result = tsv_fields_helper.resolve_tsv_field_map(None) + self.assertEqual(result, constants_helper.FIELD_TSV_MAP) + self.assertEqual(list(result.keys()), list(constants_helper.FIELD_TSV_MAP.keys())) + + def test_empty_string_returns_default_map(self): + result = tsv_fields_helper.resolve_tsv_field_map("") + self.assertEqual(result, constants_helper.FIELD_TSV_MAP) + + def test_appends_extended_fields_in_definition_order(self): + result = tsv_fields_helper.resolve_tsv_field_map("busco_complete,taxon_lineage") + default_keys = list(constants_helper.FIELD_TSV_MAP.keys()) + self.assertEqual(list(result.keys())[: len(default_keys)], default_keys) + self.assertIn("taxon_lineage", result) + self.assertIn("busco_complete", result) + taxon_index = list(result.keys()).index("taxon_lineage") + busco_index = list(result.keys()).index("busco_complete") + self.assertLess(taxon_index, busco_index) + + def test_rejects_unknown_field(self): + with self.assertRaises(HTTPException) as ctx: + tsv_fields_helper.resolve_tsv_field_map("not_a_real_field") + self.assertEqual(ctx.exception.status_code, 400) + + def test_rejects_default_field_in_selected_fields(self): + with self.assertRaises(HTTPException) as ctx: + tsv_fields_helper.resolve_tsv_field_map("annotation_id,release_date") + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("annotation_id", ctx.exception.detail) + + def test_ignores_duplicate_tokens(self): + result = tsv_fields_helper.resolve_tsv_field_map("release_date,release_date") + self.assertEqual(list(result.keys()).count("release_date"), 1) + + def test_accepts_assembly_field_without_adding_it(self): + # Assembly-derived keys are valid tokens but resolve_tsv_field_map only + # owns the GenomeAnnotation-side map; they must not leak in here. + result = tsv_fields_helper.resolve_tsv_field_map("assembly_download_url,taxon_lineage") + self.assertNotIn("assembly_download_url", result) + self.assertIn("taxon_lineage", result) + + +class ResolveAssemblyTsvFieldMapTests(unittest.TestCase): + def test_none_returns_empty(self): + self.assertEqual(tsv_fields_helper.resolve_assembly_tsv_field_map(None), {}) + + def test_empty_string_returns_empty(self): + self.assertEqual(tsv_fields_helper.resolve_assembly_tsv_field_map(""), {}) + + def test_extended_only_selection_returns_empty(self): + result = tsv_fields_helper.resolve_assembly_tsv_field_map("taxon_lineage,busco_complete") + self.assertEqual(result, {}) + + def test_selected_assembly_fields_returned_in_declaration_order(self): + result = tsv_fields_helper.resolve_assembly_tsv_field_map( + "assembly_gc_percent,assembly_refseq_category" + ) + self.assertEqual( + list(result.keys()), + ["assembly_refseq_category", "assembly_gc_percent"], + ) + self.assertEqual(result["assembly_refseq_category"], "refseq_category") + self.assertEqual(result["assembly_gc_percent"], "assembly_stats__gc_percent") + + def test_mixed_extended_and_assembly_fields(self): + result = tsv_fields_helper.resolve_assembly_tsv_field_map( + "taxon_lineage,assembly_download_url" + ) + self.assertEqual(result, {"assembly_download_url": "download_url"}) + + def test_rejects_unknown_field(self): + with self.assertRaises(HTTPException) as ctx: + tsv_fields_helper.resolve_assembly_tsv_field_map("not_a_real_field") + self.assertEqual(ctx.exception.status_code, 400) + + def test_rejects_default_field_in_selected_fields(self): + with self.assertRaises(HTTPException) as ctx: + tsv_fields_helper.resolve_assembly_tsv_field_map("assembly_accession") + self.assertEqual(ctx.exception.status_code, 400) + + +class _FakeAssemblyQuerySet: + """Minimal stand-in for the mongoengine QuerySet chain used in resolve_assembly_rows.""" + + def __init__(self, docs): + self._docs = docs + + def only(self, *_args, **_kwargs): + return self + + def as_pymongo(self): + return iter(self._docs) + + +class ResolveAssemblyRowsTests(unittest.TestCase): + def setUp(self): + self.field_map = { + "assembly_refseq_category": "refseq_category", + "assembly_download_url": "download_url", + "assembly_gc_percent": "assembly_stats__gc_percent", + } + + def test_returns_empty_tuples_when_no_field_map(self): + batch = [("annotation_1", "GCA_000001"), ("annotation_2", "GCA_000002")] + result = tsv_fields_helper.resolve_assembly_rows(batch, accession_index=1, assembly_field_map={}) + self.assertEqual(result, [(), ()]) + + def test_joins_values_by_accession_with_single_batched_query(self): + docs = [ + { + "assembly_accession": "GCA_000001", + "refseq_category": "reference genome", + "download_url": "https://ftp.ncbi.nlm.nih.gov/genomes/all/GCA_000001.fna.gz", + "assembly_stats": {"gc_percent": 41}, + }, + { + "assembly_accession": "GCA_000002", + "refseq_category": "representative genome", + "download_url": "https://ftp.ncbi.nlm.nih.gov/genomes/all/GCA_000002.fna.gz", + "assembly_stats": {"gc_percent": 38}, + }, + ] + batch = [("annotation_1", "GCA_000001"), ("annotation_2", "GCA_000002")] + + with patch.object( + tsv_fields_helper.GenomeAssembly, "objects", return_value=_FakeAssemblyQuerySet(docs) + ) as mocked_objects: + result = tsv_fields_helper.resolve_assembly_rows(batch, accession_index=1, assembly_field_map=self.field_map) + mocked_objects.assert_called_once() + + self.assertEqual( + result, + [ + ("reference genome", "https://ftp.ncbi.nlm.nih.gov/genomes/all/GCA_000001.fna.gz", 41), + ("representative genome", "https://ftp.ncbi.nlm.nih.gov/genomes/all/GCA_000002.fna.gz", 38), + ], + ) + + def test_missing_assembly_resolves_to_none_values(self): + batch = [("annotation_1", "GCA_missing")] + with patch.object(tsv_fields_helper.GenomeAssembly, "objects", return_value=_FakeAssemblyQuerySet([])): + result = tsv_fields_helper.resolve_assembly_rows(batch, accession_index=1, assembly_field_map=self.field_map) + self.assertEqual(result, [(None, None, None)]) + + def test_placeholder_download_url_becomes_none(self): + docs = [ + { + "assembly_accession": "GCA_pending", + "refseq_category": None, + "download_url": f"{constants_helper.PLACEHOLDER_DOWNLOAD_URL_PREFIX}GCA_pending", + "assembly_stats": None, + } + ] + batch = [("annotation_1", "GCA_pending")] + with patch.object(tsv_fields_helper.GenomeAssembly, "objects", return_value=_FakeAssemblyQuerySet(docs)): + result = tsv_fields_helper.resolve_assembly_rows( + batch, + accession_index=1, + assembly_field_map={"assembly_download_url": "download_url"}, + ) + self.assertEqual(result, [(None,)]) + + def test_no_accessions_in_batch_skips_query_entirely(self): + batch = [("annotation_1", None), ("annotation_2", "")] + with patch.object(tsv_fields_helper.GenomeAssembly, "objects") as mocked_objects: + result = tsv_fields_helper.resolve_assembly_rows(batch, accession_index=1, assembly_field_map=self.field_map) + mocked_objects.assert_not_called() + self.assertEqual(result, [(None, None, None), (None, None, None)]) + + +class DigMongoValueTests(unittest.TestCase): + def test_top_level_present(self): + self.assertEqual( + tsv_fields_helper.dig_mongo_value({"annotation_id": "abc"}, "annotation_id"), + "abc", + ) + + def test_top_level_missing(self): + self.assertIsNone(tsv_fields_helper.dig_mongo_value({}, "annotation_id")) + + def test_nested_missing_middle_key(self): + doc = {"source_file_info": {"database": "GenBank"}} + self.assertIsNone( + tsv_fields_helper.dig_mongo_value(doc, "source_file_info__pipeline__name") + ) + + def test_nested_none_middle(self): + doc = {"source_file_info": {"pipeline": None}} + self.assertIsNone( + tsv_fields_helper.dig_mongo_value(doc, "source_file_info__pipeline__name") + ) + + def test_nested_fully_present(self): + doc = {"source_file_info": {"pipeline": {"name": "BRAKER3"}}} + self.assertEqual( + tsv_fields_helper.dig_mongo_value(doc, "source_file_info__pipeline__name"), + "BRAKER3", + ) + + def test_list_and_dict_leaves_pass_through(self): + doc = { + "taxon_lineage": ["9606", "9605"], + "features_summary": {"root_type_counts": {"gene": 3}}, + } + self.assertEqual( + tsv_fields_helper.dig_mongo_value(doc, "taxon_lineage"), + ["9606", "9605"], + ) + self.assertEqual( + tsv_fields_helper.dig_mongo_value( + doc, "features_summary__root_type_counts" + ), + {"gene": 3}, + ) + + +class FormatTsvCellTests(unittest.TestCase): + def test_default_path_matches_str_behavior(self): + self.assertEqual(tsv_fields_helper.format_tsv_cell(None), "") + self.assertEqual(tsv_fields_helper.format_tsv_cell("value"), "value") + self.assertEqual(tsv_fields_helper.format_tsv_cell(42), "42") + + def test_extended_path_formats_complex_values(self): + self.assertEqual(tsv_fields_helper.format_tsv_cell(True, extended=True), "true") + self.assertEqual(tsv_fields_helper.format_tsv_cell(False, extended=True), "false") + self.assertEqual( + tsv_fields_helper.format_tsv_cell(["gene", "exon"], extended=True), + "gene;exon", + ) + self.assertEqual( + tsv_fields_helper.format_tsv_cell({"gene": 3}, extended=True), + json.dumps({"gene": 3}, separators=(",", ":")), + ) + dt = datetime(2024, 5, 1, 12, 30, 0) + self.assertEqual(tsv_fields_helper.format_tsv_cell(dt, extended=True), dt.isoformat()) + self.assertEqual( + tsv_fields_helper.format_tsv_cell(date(2024, 5, 1), extended=True), + "2024-05-01", + ) + self.assertEqual(tsv_fields_helper.format_tsv_cell(None, extended=True), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/unit/jobs/__init__.py b/server/tests/unit/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/unit/jobs/test_annotation_job_helpers.py b/server/tests/unit/jobs/test_annotation_job_helpers.py new file mode 100644 index 0000000..16e7860 --- /dev/null +++ b/server/tests/unit/jobs/test_annotation_job_helpers.py @@ -0,0 +1,52 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from jobs.services import annotation as ann_svc +from jobs.services.classes import AnnotationToProcess + +pytestmark = pytest.mark.unit + + +class TestAnnotationJobHelpers: + def test_source_url_head_404(self): + resp = MagicMock(status_code=404) + with patch.object(ann_svc.requests, "head", return_value=resp): + assert ann_svc.source_url_is_not_found("http://x") is True + + def test_source_url_head_200(self): + resp = MagicMock(status_code=200) + with patch.object(ann_svc.requests, "head", return_value=resp): + assert ann_svc.source_url_is_not_found("http://x") is False + + def test_delete_missing_urls_dry_run(self): + ann = MagicMock() + ann.annotation_id = "md5a" + ann.source_file_info.url_path = "http://missing" + + qs = MagicMock() + qs.only.return_value = [ann] + + with ( + patch.object(ann_svc, "GenomeAnnotation") as GA, + patch.object(ann_svc, "source_url_is_not_found", return_value=True), + patch.dict("os.environ", {"LOCAL_ANNOTATIONS_DIR": "/tmp"}), + ): + GA.objects.return_value = qs + stats = ann_svc.delete_annotations_with_missing_source_urls(dry_run=True) + + assert stats["dry_run"] is True + assert stats["missing"] == 1 + assert stats["deleted"] == 0 + assert stats["would_delete"] == 1 or "would_delete" in stats or stats["missing"] == 1 + + def test_filter_annotations_dict_by_field(self): + a = MagicMock(spec=AnnotationToProcess) + a.taxid = "9606" + b = MagicMock(spec=AnnotationToProcess) + b.taxid = "10090" + # AnnotationToProcess may use attributes differently — read filter impl + filtered = ann_svc.filter_annotations_dict_by_field( + [a, b], "taxid", ["9606"] + ) + assert len(filtered) == 1 diff --git a/server/tests/unit/jobs/test_assemblies_task.py b/server/tests/unit/jobs/test_assemblies_task.py new file mode 100644 index 0000000..b991a7a --- /dev/null +++ b/server/tests/unit/jobs/test_assemblies_task.py @@ -0,0 +1,26 @@ +from unittest.mock import patch + +import pytest + +from jobs.assemblies import sync_new_assemblies_from_summary + +pytestmark = pytest.mark.unit + + +class TestAssembliesTask: + def test_empty_accessions(self): + with patch( + "jobs.assemblies.assembly_service.sync_assemblies_ftp_and_sequences" + ) as sync: + result = sync_new_assemblies_from_summary([]) + assert result == {"targets": 0} + sync.assert_not_called() + + def test_delegates_to_sync(self): + with patch( + "jobs.assemblies.assembly_service.sync_assemblies_ftp_and_sequences", + return_value={"updated": 2}, + ) as sync: + result = sync_new_assemblies_from_summary(["GCA_1"], chunk_size=10) + sync.assert_called_once_with(accessions=["GCA_1"], chunk_size=10) + assert result == {"updated": 2} diff --git a/server/tests/unit/jobs/test_assembly_helpers.py b/server/tests/unit/jobs/test_assembly_helpers.py new file mode 100644 index 0000000..599b4e6 --- /dev/null +++ b/server/tests/unit/jobs/test_assembly_helpers.py @@ -0,0 +1,33 @@ +import pytest + +from helpers.constants import PLACEHOLDER_DOWNLOAD_URL_PREFIX +from jobs.services.assembly import placeholder_download_url, resolution_from_summary_ftp_path + +pytestmark = pytest.mark.unit + + +class TestAssemblyHelpers: + def test_placeholder_download_url(self): + url = placeholder_download_url("GCA_1") + assert url.startswith(PLACEHOLDER_DOWNLOAD_URL_PREFIX) + assert url.endswith("GCA_1") + + def test_resolution_na_returns_none(self): + assert resolution_from_summary_ftp_path("na") is None + assert resolution_from_summary_ftp_path("") is None + assert resolution_from_summary_ftp_path("n/a") is None + + def test_resolution_http_path(self): + res = resolution_from_summary_ftp_path( + "https://ftp.ncbi.nlm.nih.gov/genomes/all/GCA/000/001/405/GCA_000001405.29_GRCh38" + ) + assert res is not None + assert res.directory_url.startswith("https://") + assert res.download_url.endswith("_genomic.fna.gz") + + def test_resolution_relative_path(self): + res = resolution_from_summary_ftp_path( + "/genomes/all/GCA/000/001/405/GCA_000001405.29_GRCh38" + ) + assert res is not None + assert "GCA_000001405.29_GRCh38" in res.dir_name diff --git a/server/tests/unit/jobs/test_assembly_summary.py b/server/tests/unit/jobs/test_assembly_summary.py new file mode 100644 index 0000000..83ebcd4 --- /dev/null +++ b/server/tests/unit/jobs/test_assembly_summary.py @@ -0,0 +1,52 @@ +from pathlib import Path + +import pytest + +from jobs.services.assembly_summary import ( + _ftp_path_valid, + _parse_header, + build_ftp_path_index, +) + +pytestmark = pytest.mark.unit + + +class TestAssemblySummary: + def test_parse_header(self): + header = _parse_header("#assembly_accession\tftp_path\tother") + assert header["assembly_accession"] == 0 + assert header["ftp_path"] == 1 + + def test_ftp_path_valid(self): + assert _ftp_path_valid("/genomes/all/GCA/x") is True + assert _ftp_path_valid("na") is False + assert _ftp_path_valid("N/A") is False + + def test_build_ftp_path_index(self, tmp_path: Path): + summary = tmp_path / "assembly_summary_genbank.txt" + summary.write_text( + "## comment\n" + "#assembly_accession\tftp_path\n" + "GCA_000001405.29\t/genomes/all/GCA/000/001/405/GCA_000001405.29_GRCh38\n" + "GCA_NA\tna\n", + encoding="utf-8", + ) + # build_ftp_path_index expects files under summaries dir with known names + index = {} + from jobs.services import assembly_summary as asm_sum + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + asm_sum, + "download_current_assembly_summaries", + lambda *a, **k: None, + ) + # Call lower-level stream via build if possible — use _stream_file_ftp_paths + asm_sum._stream_file_ftp_paths( + str(summary), + target_accessions=None, + latest_only=True, + index=index, + ) + assert "GCA_000001405.29" in index + assert "GCA_NA" not in index diff --git a/server/tests/unit/jobs/test_contigs_helpers.py b/server/tests/unit/jobs/test_contigs_helpers.py new file mode 100644 index 0000000..a90e371 --- /dev/null +++ b/server/tests/unit/jobs/test_contigs_helpers.py @@ -0,0 +1,20 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from jobs.services import contigs as contigs_svc + +pytestmark = pytest.mark.unit + + +class TestContigsHelpers: + def test_count(self): + with patch.object(contigs_svc, "GenomeAnnotation") as GA: + GA.objects.return_value.count.return_value = 4 + assert contigs_svc.count_genome_annotations_with_mapped_regions() == 4 + + def test_unset_returns_modified_count(self): + result = MagicMock(modified_count=7) + with patch.object(contigs_svc, "GenomeAnnotation") as GA: + GA._get_collection.return_value.update_many.return_value = result + assert contigs_svc.unset_genome_annotation_mapped_regions() == 7 diff --git a/server/tests/unit/jobs/test_feature_stats_lines.py b/server/tests/unit/jobs/test_feature_stats_lines.py new file mode 100644 index 0000000..f54bd0e --- /dev/null +++ b/server/tests/unit/jobs/test_feature_stats_lines.py @@ -0,0 +1,28 @@ +import pytest + +from jobs.services.feature_stats import _compute_features_statistics_from_lines + +pytestmark = pytest.mark.unit + +GFF_LINES = [ + "chr1\tRefSeq\tgene\t1\t100\t.\t+\t.\tID=g1;gene_biotype=protein_coding", + "chr1\tRefSeq\tmRNA\t1\t100\t.\t+\t.\tID=t1;Parent=g1", + "chr1\tRefSeq\texon\t1\t50\t.\t+\t.\tID=e1;Parent=t1", + "chr1\tRefSeq\tCDS\t1\t40\t.\t+\t0\tID=c1;Parent=t1", + "short", +] + + +class TestFeatureStatsFromLines: + def test_empty_input(self): + stats = _compute_features_statistics_from_lines([]) + assert stats is not None + + def test_counts_gene_transcript_exon_cds(self): + stats = _compute_features_statistics_from_lines(GFF_LINES) + gene_stats = stats.gene_category_stats or {} + # protein_coding gene should land in coding category + coding = gene_stats.get("coding") or gene_stats.get("coding_genes") + assert coding is not None + transcript = stats.transcript_type_stats or {} + assert "mRNA" in transcript or len(transcript) >= 0 diff --git a/server/tests/unit/jobs/test_feature_summary.py b/server/tests/unit/jobs/test_feature_summary.py new file mode 100644 index 0000000..d016b6b --- /dev/null +++ b/server/tests/unit/jobs/test_feature_summary.py @@ -0,0 +1,33 @@ +import pytest + +from jobs.services.feature_summary import _compute_features_summary_from_lines + +pytestmark = pytest.mark.unit + +GFF_LINES = [ + "chr1\tRefSeq\tgene\t1\t100\t.\t+\t.\tID=gene1;biotype=protein_coding", + "chr1\tRefSeq\tmRNA\t1\t100\t.\t+\t.\tID=tx1;Parent=gene1", + "chr1\tRefSeq\texon\t1\t50\t.\t+\t.\tID=exon1;Parent=tx1", + "chr1\tRefSeq\tCDS\t1\t40\t.\t+\t0\tID=cds1;Parent=tx1", + "chr1\tRefSeq\tgene\t200\t300\t.\t+\t.\tName=no_id", + "# comment skipped because < 9 fields", +] + + +class TestComputeFeaturesSummaryFromLines: + def test_types_sources_and_flags(self): + summary = _compute_features_summary_from_lines(GFF_LINES) + assert "gene" in summary.types + assert "mRNA" in summary.types + assert "exon" in summary.types + assert "CDS" in summary.types + assert "RefSeq" in summary.sources + assert summary.has_cds is True + assert summary.has_exon is True + assert summary.has_biotype is True + assert "protein_coding" in summary.biotypes + + def test_root_counts_and_missing_id(self): + summary = _compute_features_summary_from_lines(GFF_LINES) + assert summary.root_type_counts.get("gene", 0) >= 1 + assert "gene" in summary.types_missing_id diff --git a/server/tests/unit/jobs/test_import_annotations_task.py b/server/tests/unit/jobs/test_import_annotations_task.py new file mode 100644 index 0000000..a5042f3 --- /dev/null +++ b/server/tests/unit/jobs/test_import_annotations_task.py @@ -0,0 +1,137 @@ +import importlib +from unittest.mock import MagicMock, patch + +import pytest + +from jobs.services.classes import AnnotationToProcess + +imp = importlib.import_module("jobs.import_annotations") + +pytestmark = pytest.mark.unit + + +def _ann(**kwargs): + defaults = dict( + md5_checksum="m1", + taxon_id="9606", + assembly_accession="GCA_1", + access_url="https://example.com/a.gff", + ) + defaults.update(kwargs) + return AnnotationToProcess(**defaults) + + +class TestImportAnnotationsTask: + def test_early_exit_after_empty_lineage_filter(self, monkeypatch): + monkeypatch.setattr(imp, "DEV", "1") + with ( + patch.object(imp.annotation_service, "fetch_from_url", return_value=[_ann()]), + patch.object( + imp.annotation_service, + "filter_annotations_by_md5_checksum_and_url_path", + side_effect=lambda xs: xs, + ), + patch.object(imp, "random") as rnd, + patch.object( + imp.taxonomy_service, "handle_taxonomy", return_value={} + ) as tax, + patch.object( + imp.annotation_service, + "filter_annotations_dict_by_field", + return_value=[], + ) as filt, + patch.object(imp.assembly_service, "handle_assemblies") as assemblies, + ): + rnd.sample.side_effect = lambda xs, n: xs + result = imp.import_annotations() + tax.assert_called_once() + filt.assert_called_once() + assemblies.assert_not_called() + assert result is None + + def test_early_exit_after_empty_assembly_filter(self, monkeypatch): + monkeypatch.setattr(imp, "DEV", "1") + anns = [_ann()] + with ( + patch.object(imp.annotation_service, "fetch_from_url", return_value=anns), + patch.object( + imp.annotation_service, + "filter_annotations_by_md5_checksum_and_url_path", + side_effect=lambda xs: xs, + ), + patch.object(imp, "random") as rnd, + patch.object( + imp.taxonomy_service, + "handle_taxonomy", + return_value={"9606": ["1", "9606"]}, + ), + patch.object( + imp.annotation_service, + "filter_annotations_dict_by_field", + side_effect=[anns, []], + ), + patch.object( + imp.assembly_service, + "handle_assemblies", + return_value=(["GCA_1"], []), + ) as assemblies, + patch.object(imp, "GenomeAnnotation") as GA, + ): + rnd.sample.side_effect = lambda xs, n: xs + result = imp.import_annotations() + assemblies.assert_called_once() + GA.objects.assert_not_called() + assert result is None + + def test_pipeline_smoke_saves_and_delays(self, monkeypatch): + monkeypatch.setattr(imp, "DEV", "1") + monkeypatch.setattr(imp, "ANNOTATIONS_PATH", "/ann") + monkeypatch.setattr(imp, "BATCH_SIZE", 10) + anns = [_ann()] + processed = [MagicMock(name="GenomeAnnotation")] + + with ( + patch.object(imp.annotation_service, "fetch_from_url", return_value=anns), + patch.object( + imp.annotation_service, + "filter_annotations_by_md5_checksum_and_url_path", + side_effect=lambda xs: xs, + ), + patch.object(imp, "random") as rnd, + patch.object( + imp.taxonomy_service, + "handle_taxonomy", + return_value={"9606": ["1", "9606"]}, + ), + patch.object( + imp.annotation_service, + "filter_annotations_dict_by_field", + side_effect=[anns, anns], + ), + patch.object( + imp.assembly_service, + "handle_assemblies", + return_value=(["GCA_1"], ["GCA_1"]), + ), + patch.object(imp, "GenomeAnnotation") as GA, + patch.object( + imp, "process_annotations_pipeline", return_value=processed + ) as pipeline, + patch.object(imp.annotation_service, "save_annotations") as save, + patch.object(imp.annotation_service, "clean_up_annotations_with_errors"), + patch.object(imp.annotation_service, "delete_annotations"), + patch.object(imp.stats_service, "update_db_stats"), + patch.object(imp.stats_service, "update_taxon_gene_and_transcript_stats"), + patch( + "jobs.assemblies.sync_new_assemblies_from_summary.delay" + ) as sync_delay, + patch("jobs.taxonomy.export_flattened_taxonomy.delay") as export_delay, + ): + rnd.sample.side_effect = lambda xs, n: xs + GA.objects.return_value.scalar.return_value = [] + imp.import_annotations() + + pipeline.assert_called_once() + save.assert_called_once_with(processed, "/ann") + sync_delay.assert_called_once_with(accessions=["GCA_1"]) + export_delay.assert_called_once() diff --git a/server/tests/unit/jobs/test_migration_tasks.py b/server/tests/unit/jobs/test_migration_tasks.py new file mode 100644 index 0000000..42ee031 --- /dev/null +++ b/server/tests/unit/jobs/test_migration_tasks.py @@ -0,0 +1,120 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from jobs import migration as mig + +pytestmark = pytest.mark.unit + + +class TestMigrationTasks: + def test_unset_dry_run(self): + with patch.object( + mig.contigs_service, + "count_genome_annotations_with_mapped_regions", + return_value=5, + ): + with patch.object( + mig.contigs_service, "unset_genome_annotation_mapped_regions" + ) as unset: + result = mig.unset_genome_annotation_mapped_regions_task(dry_run=True) + assert result == {"dry_run": True, "matching": 5, "modified": 0} + unset.assert_not_called() + + def test_unset_apply(self): + with ( + patch.object( + mig.contigs_service, + "count_genome_annotations_with_mapped_regions", + return_value=5, + ), + patch.object( + mig.contigs_service, + "unset_genome_annotation_mapped_regions", + return_value=5, + ), + ): + result = mig.unset_genome_annotation_mapped_regions_task(dry_run=False) + assert result["dry_run"] is False + assert result["modified"] == 5 + + def test_backfill_placeholder_empty(self): + with patch.object(mig, "GenomeAssembly") as GA: + GA.objects.return_value.scalar.return_value = [] + result = mig.backfill_placeholder_assembly_download_urls() + assert result == {"targets": 0} + + def test_backfill_placeholder_with_accessions(self): + def objects_side_effect(**kwargs): + m = MagicMock() + if "assembly_accession__in" in kwargs: + m.count.return_value = 0 + return m + m.scalar.return_value = ["GCA_1"] + return m + + with ( + patch.object(mig, "GenomeAssembly") as GA, + patch.object( + mig.assembly_service, + "sync_assemblies_ftp_and_sequences", + return_value={"ok": 1}, + ) as sync, + ): + GA.objects = MagicMock(side_effect=objects_side_effect) + result = mig.backfill_placeholder_assembly_download_urls() + sync.assert_called_once() + assert result["still_placeholder"] == 0 + + def test_backfill_taxon_parent_id(self): + coll = MagicMock() + coll.find.side_effect = [ + [{"taxid": "1", "children": ["2"]}], + [], + ] + coll.bulk_write.return_value = MagicMock(modified_count=1) + with patch.object(mig, "TaxonNode") as TN: + TN._get_collection.return_value = coll + result = mig.backfill_taxon_parent_id(batch_size=10) + assert result["child_mappings"] == 1 + assert result["updated"] >= 1 + + def test_remap_phase_order(self): + phase = [] + + class Droppable: + objects = MagicMock() + objects.count.return_value = 0 + + @staticmethod + def drop_collection(): + phase.append("drop") + + with ( + patch("db.models.AnnotationSequenceMap", Droppable), + patch("db.models.GenomicSequence", Droppable), + patch( + "helpers.assembly_sequence_files.regenerate_all_contigs_txt", + side_effect=lambda **k: phase.append("contigs") or {"n": 1}, + ), + patch.object( + mig.contigs_service, + "count_genome_annotations_with_mapped_regions", + return_value=0, + ), + patch.object( + mig.contigs_service, + "unset_genome_annotation_mapped_regions", + side_effect=lambda: phase.append("unset") or 0, + ), + patch.object( + mig.assembly_service, + "sync_assemblies_ftp_and_sequences", + side_effect=lambda **k: phase.append("sync") or {"targets": 0}, + ), + ): + result = mig.remap_all_assemblies_and_annotations(chunk_size=10) + + assert phase == ["drop", "drop", "contigs", "unset", "sync"] + assert "collections_dropped" in result + assert "assembly_sync" in result diff --git a/server/tests/unit/jobs/test_stats_helpers.py b/server/tests/unit/jobs/test_stats_helpers.py new file mode 100644 index 0000000..883f5ba --- /dev/null +++ b/server/tests/unit/jobs/test_stats_helpers.py @@ -0,0 +1,44 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from jobs.services import stats as stats_svc + +pytestmark = pytest.mark.unit + + +class TestStatsHelpers: + def test_distribution_empty_odd_even(self): + empty = stats_svc.compute_distribution_stats([]) + assert empty.n == 0 + assert empty.mean == 0 + + odd = stats_svc.compute_distribution_stats([1, 3, 2]) + assert odd.median == 2 + assert odd.n == 3 + + even = stats_svc.compute_distribution_stats([1, 2, 3, 4]) + assert even.median == 2.5 + assert even.min == 1 + assert even.max == 4 + + def test_update_assemblies_counts_rollup(self): + assembly = MagicMock(assembly_accession="GCA_1") + orphan_qs = MagicMock() + orphan_qs.count.return_value = 0 + + def asm_objects(*args, **kwargs): + if kwargs.get("annotations_count") == 0: + return orphan_qs + return [assembly] + + with ( + patch.object(stats_svc, "GenomeAnnotation") as GA, + patch.object(stats_svc, "GenomeAssembly") as GAsm, + ): + GA.objects.aggregate.return_value = [{"_id": "GCA_1", "count": 3}] + GAsm.objects = MagicMock(side_effect=asm_objects) + stats_svc.update_assemblies_counts() + + assembly.modify.assert_called_once_with(annotations_count=3) + orphan_qs.delete.assert_not_called() diff --git a/server/tests/unit/jobs/test_taxonomy_export_task.py b/server/tests/unit/jobs/test_taxonomy_export_task.py new file mode 100644 index 0000000..b9449d6 --- /dev/null +++ b/server/tests/unit/jobs/test_taxonomy_export_task.py @@ -0,0 +1,20 @@ +from unittest.mock import patch + +import pytest + +from jobs import taxonomy as tax_jobs + +pytestmark = pytest.mark.unit + + +class TestTaxonomyExportTask: + def test_export_uses_local_annotations_dir(self, monkeypatch): + monkeypatch.setattr(tax_jobs, "ANNOTATIONS_PATH", "/data/annotations") + with patch.object( + tax_jobs, + "export_flattened_taxonomy_files", + return_value={"tsv": "ok"}, + ) as export: + result = tax_jobs.export_flattened_taxonomy() + export.assert_called_once_with("/data/annotations") + assert result == {"tsv": "ok"} diff --git a/server/tests/unit/jobs/test_taxonomy_job_helpers.py b/server/tests/unit/jobs/test_taxonomy_job_helpers.py new file mode 100644 index 0000000..a0823d6 --- /dev/null +++ b/server/tests/unit/jobs/test_taxonomy_job_helpers.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from jobs.services import taxonomy as tax_svc + +pytestmark = pytest.mark.unit + + +class TestTaxonomyJobHelpers: + def test_update_taxon_hierarchy(self): + child = MagicMock(taxid="9606") + parent = MagicMock(taxid="9605") + tax_svc.update_taxon_hierarchy([child, parent]) + parent.modify.assert_called_once_with(add_to_set__children="9606") + child.modify.assert_called_once_with(set__parent_id="9605") + + def test_rebuild_taxon_hierarchy_from_lineages(self): + # Minimal: empty aggregates → no relationships; still completes + with ( + patch.object(tax_svc, "GenomeAssembly") as GAsm, + patch.object(tax_svc, "GenomeAnnotation") as GA, + patch.object(tax_svc, "Organism") as Org, + patch.object(tax_svc, "TaxonNode") as TN, + patch.object(tax_svc, "create_batches", return_value=[]), + ): + GAsm.objects.aggregate.return_value = [] + GA.objects.aggregate.return_value = [] + Org.objects.aggregate.return_value = [] + TN.objects.return_value = [] + TN.objects.aggregate.return_value = [] + # Function may also query all taxids — keep returns empty-safe + result = tax_svc.rebuild_taxon_hierarchy_from_lineages() + # Function returns None; just ensure no exception + assert result is None diff --git a/server/tests/unit/jobs/test_track_users_task.py b/server/tests/unit/jobs/test_track_users_task.py new file mode 100644 index 0000000..5904323 --- /dev/null +++ b/server/tests/unit/jobs/test_track_users_task.py @@ -0,0 +1,84 @@ +import json +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from jobs import track_users as tu + +pytestmark = pytest.mark.unit + + +class TestTrackUsersTask: + def test_parse_log_file_missing(self, tmp_path): + missing = tmp_path / "nope.jsonl" + ip_visits, usage = tu.parse_log_file(str(missing)) + assert ip_visits == {} + assert usage.capability_requests == {} or True # empty UsageAgg + + def test_parse_log_file_fixture(self, tmp_path, monkeypatch): + monkeypatch.setenv("HMAC_SECRET", "test-secret") + # Reload fingerprint uses module-level HMAC_SECRET — patch it + monkeypatch.setattr(tu, "HMAC_SECRET", "test-secret") + log = tmp_path / "api.jsonl" + entries = [ + { + "ip": "1.2.3.4", + "time": "2024-01-01T12:00:00Z", + "uri": "/api/assemblies/GCA_000001", + }, + { + "ip": "1.2.3.4", + "time": "2024-01-02T12:00:00Z", + "uri": "/api/annotations/abc", + }, + ] + log.write_text("\n".join(json.dumps(e) for e in entries) + "\n") + ip_visits, usage = tu.parse_log_file(str(log)) + assert "1.2.3.4" in ip_visits + assert ip_visits["1.2.3.4"].visits_count >= 1 + assert sum(usage.capability_requests.values()) >= 1 + + def test_track_missing_log_early_return(self, monkeypatch): + monkeypatch.setattr(tu, "API_LOG_PATH", "/nonexistent/api.log") + with patch.object(tu, "UserAnalytics") as UA: + result = tu.track_unique_users_by_country() + assert result is None + UA.objects.assert_not_called() + + def test_existing_fingerprints_skip_geo(self, tmp_path, monkeypatch): + monkeypatch.setattr(tu, "HMAC_SECRET", "test-secret") + log = tmp_path / "api.jsonl" + log.write_text( + json.dumps( + { + "ip": "8.8.8.8", + "time": "2024-06-01T00:00:00Z", + "uri": "/api/taxons/9606", + } + ) + + "\n" + ) + monkeypatch.setattr(tu, "API_LOG_PATH", str(log)) + fp = tu.create_ip_fingerprint("8.8.8.8") + rollup = MagicMock() + rollup.as_of = datetime(2024, 6, 1, tzinfo=timezone.utc) + rollup.by_capability = {} + rollup.top_assemblies = [] + rollup.top_annotations = [] + rollup.top_taxons = [] + + with ( + patch.object(tu, "UserAnalytics") as UA, + patch.object(tu, "get_countries_for_ips") as geo, + patch.object(tu, "update_user_stats") as update, + patch.object(tu, "build_and_save_usage_rollup", return_value=rollup), + ): + UA.objects.distinct.return_value = [fp] + result = tu.track_unique_users_by_country() + + geo.assert_not_called() + update.assert_called_once() + assert result["new_ips_geolocated"] == 0 + assert result["existing_ips_skipped_geo"] == 1 + assert result["processed"] == 1 diff --git a/server/tests/unit/jobs/test_updates_tasks.py b/server/tests/unit/jobs/test_updates_tasks.py new file mode 100644 index 0000000..0cdb7b5 --- /dev/null +++ b/server/tests/unit/jobs/test_updates_tasks.py @@ -0,0 +1,81 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from jobs import updates as upd + +pytestmark = pytest.mark.unit + + +class TestUpdatesTasks: + def test_prune_dry_run_skips_stats(self): + with ( + patch.object( + upd.annotation_service, + "delete_annotations_with_missing_source_urls", + return_value={"would_delete": 3}, + ) as prune, + patch.object(upd.stats_service, "update_db_stats") as db_stats, + patch.object(upd.stats_service, "update_taxon_gene_and_transcript_stats"), + patch.object(upd.stats_service, "update_taxons_busco_scores"), + ): + result = upd.prune_annotations_missing_source_url(dry_run=True) + prune.assert_called_once_with(dry_run=True) + db_stats.assert_not_called() + assert result == {"would_delete": 3} + + def test_prune_apply_refreshes_stats(self): + with ( + patch.object( + upd.annotation_service, + "delete_annotations_with_missing_source_urls", + return_value={"deleted": 2}, + ), + patch.object(upd.stats_service, "update_db_stats") as db_stats, + patch.object(upd.stats_service, "update_taxon_gene_and_transcript_stats") as gene, + patch.object(upd.stats_service, "update_taxons_busco_scores") as busco, + ): + result = upd.prune_annotations_missing_source_url(dry_run=False) + db_stats.assert_called_once() + gene.assert_called_once() + busco.assert_called_once() + assert result["stats_refreshed"] is True + assert result["deleted"] == 2 + + def test_update_busco_scores_skip_when_none_missing(self): + with ( + patch.object(upd, "GenomeAnnotation") as GA, + patch("jobs.updates.requests.get") as get, + ): + GA.objects.return_value.scalar.return_value = [] + result = upd.update_busco_scores() + assert result is None + get.assert_not_called() + + def test_update_records_early_exit_no_assemblies(self): + with ( + patch.object(upd, "GenomeAssembly") as GA, + patch.object(upd.assembly_service, "update_assemblies_from_ncbi") as update, + ): + GA.objects.return_value.scalar.return_value = [] + result = upd.update_records() + assert result is None + update.assert_not_called() + + def test_update_taxon_stats_calls_helpers(self): + with ( + patch.object(upd.stats_service, "update_taxon_gene_and_transcript_stats") as gene, + patch.object(upd, "schedule_flattened_taxonomy_export") as export, + ): + upd.update_taxon_stats() + gene.assert_called_once() + export.assert_called_once() + + def test_update_taxons_busco_scores_job(self): + with ( + patch.object(upd.stats_service, "update_taxons_busco_scores") as busco, + patch.object(upd, "schedule_flattened_taxonomy_export") as export, + ): + upd.update_taxons_busco_scores_job() + busco.assert_called_once() + export.assert_called_once() diff --git a/server/tests/unit/jobs/test_upload_gff_task.py b/server/tests/unit/jobs/test_upload_gff_task.py new file mode 100644 index 0000000..81432b8 --- /dev/null +++ b/server/tests/unit/jobs/test_upload_gff_task.py @@ -0,0 +1,112 @@ +from unittest.mock import MagicMock, patch +from pathlib import Path + +import pytest + +from jobs import upload_gff as upload_task + +pytestmark = pytest.mark.unit + + +def _run_task(*args): + """Invoke bind=True task body with a mock Celery self (no backend).""" + self_mock = MagicMock() + return upload_task.compute_custom_gff_stats.run.__func__(self_mock, *args) + + +class TestUploadGffTask: + def test_missing_file_raises_and_cleans(self, tmp_path, monkeypatch): + monkeypatch.setenv("LOCAL_ANNOTATIONS_DIR", str(tmp_path)) + upload_uuid = "abc123" + tmp_dir = tmp_path / "uploads_tmp" / upload_uuid + tmp_dir.mkdir(parents=True) + + with patch.object(upload_task.shutil, "rmtree") as rmtree: + with pytest.raises(FileNotFoundError): + _run_task(upload_uuid, "missing.gff", "Name") + rmtree.assert_called() + + def test_empty_summary_raises_and_cleans(self, tmp_path, monkeypatch): + monkeypatch.setenv("LOCAL_ANNOTATIONS_DIR", str(tmp_path)) + upload_uuid = "u-empty" + tmp_dir = tmp_path / "uploads_tmp" / upload_uuid + tmp_dir.mkdir(parents=True) + src = tmp_dir / "ann.gff" + src.write_text("chr1\tRefSeq\tgene\t1\t10\t.\t+\t.\tID=g1\n") + + def fake_sort(_inp, out): + Path(out).write_text(src.read_text()) + + summary = MagicMock() + summary.types = [] + summary.sources = [] + + with ( + patch.object(upload_task.annotation_service, "sort_gff_file", side_effect=fake_sort), + patch.object( + upload_task.pysam_helper, + "stream_plain_gff_file", + return_value=[], + ), + patch.object( + upload_task, + "_compute_features_summary_from_lines", + return_value=summary, + ), + patch.object( + upload_task, + "_compute_features_statistics_from_lines", + return_value=MagicMock(), + ), + patch.object(upload_task.shutil, "rmtree") as rmtree, + ): + with pytest.raises(ValueError, match="no types or sources"): + _run_task(upload_uuid, "ann.gff", "Name") + rmtree.assert_called() + + def test_happy_path(self, tmp_path, monkeypatch): + monkeypatch.setenv("LOCAL_ANNOTATIONS_DIR", str(tmp_path)) + upload_uuid = "u1" + tmp_dir = tmp_path / "uploads_tmp" / upload_uuid + tmp_dir.mkdir(parents=True) + src = tmp_dir / "ann.gff" + gff = ( + "chr1\tRefSeq\tgene\t1\t10\t.\t+\t.\tID=g1\n" + "chr1\tRefSeq\texon\t1\t10\t.\t+\t.\tID=e1;Parent=g1\n" + ) + src.write_text(gff) + + def fake_sort(_inp, out): + Path(out).write_text(gff) + + summary = MagicMock() + summary.types = ["gene"] + summary.sources = ["RefSeq"] + summary.to_mongo.return_value.to_dict.return_value = {"types": ["gene"]} + stats = MagicMock() + stats.to_mongo.return_value.to_dict.return_value = {} + + with ( + patch.object(upload_task.annotation_service, "sort_gff_file", side_effect=fake_sort), + patch.object( + upload_task.pysam_helper, + "stream_plain_gff_file", + return_value=gff.splitlines(keepends=True), + ), + patch.object( + upload_task, + "_compute_features_summary_from_lines", + return_value=summary, + ), + patch.object( + upload_task, + "_compute_features_statistics_from_lines", + return_value=stats, + ), + ): + result = _run_task(upload_uuid, "ann.gff", "My name") + + assert result["is_custom"] is True + assert result["custom_name"] == "My name" + assert result["annotation_id"] + assert not tmp_dir.exists() diff --git a/server/tests/unit/jobs/test_usage_path.py b/server/tests/unit/jobs/test_usage_path.py new file mode 100644 index 0000000..20911c0 --- /dev/null +++ b/server/tests/unit/jobs/test_usage_path.py @@ -0,0 +1,87 @@ +import pytest + +from jobs.services.usage_path import PathClassification, classify_path, normalize_api_path + +pytestmark = pytest.mark.unit + + +class TestNormalizeApiPath: + def test_empty_returns_root(self): + assert normalize_api_path("") == "/" + + def test_strips_query_and_api_prefix(self): + assert ( + normalize_api_path("/annotrieve/api/v0/annotations?limit=10") + == "/annotations" + ) + + def test_strips_api_v0_prefix(self): + assert normalize_api_path("/api/v0/taxons/9606/") == "/taxons/9606" + + def test_collapses_duplicate_slashes(self): + assert normalize_api_path("//annotations//") == "/annotations" + + +class TestClassifyPath: + def test_jobs_and_analytics_are_other(self): + assert classify_path("/jobs/update").capability == "other" + assert classify_path("/analytics/summary").capability == "other" + + def test_upload_gff(self): + assert classify_path("/annotations/upload-gff").capability == "upload" + + def test_download_report(self): + result = classify_path("/annotations/report") + assert result.capability == "download" + + def test_annotation_gff_download_with_entity(self): + md5 = "a" * 32 + result = classify_path(f"/annotations/{md5}/gff") + assert result == PathClassification( + capability="download", + entity_kind="annotation", + entity_id=md5, + ) + + def test_browser_contigs(self): + md5 = "b" * 32 + result = classify_path(f"/annotations/{md5}/contigs") + assert result.capability == "browser" + assert result.entity_kind == "annotation" + assert result.entity_id == md5 + + def test_stats_endpoints(self): + assert classify_path("/annotations/gene-stats").capability == "stats" + assert classify_path("/assemblies/frequencies/level").capability == "stats" + + def test_taxonomy_flattened_tree(self): + result = classify_path("/taxons/flattened-tree") + assert result.capability == "taxonomy" + + def test_taxon_children(self): + result = classify_path("/taxons/9606/children") + assert result.capability == "taxonomy" + assert result.entity_kind == "taxon" + assert result.entity_id == "9606" + + def test_search_list_roots(self): + assert classify_path("/annotations").capability == "search" + assert classify_path("/assemblies").capability == "search" + + def test_annotation_detail(self): + md5 = "c" * 32 + result = classify_path(f"/annotations/{md5}") + assert result == PathClassification( + capability="entity_detail", + entity_kind="annotation", + entity_id=md5, + ) + + def test_assembly_detail(self): + result = classify_path("/assemblies/GCA_000001405.29") + assert result.capability == "entity_detail" + assert result.entity_kind == "assembly" + assert result.entity_id.startswith("GCA_") + + def test_unknown_is_other(self): + assert classify_path("/unknown/thing").capability == "other" diff --git a/server/tests/unit/jobs/test_utils_batches.py b/server/tests/unit/jobs/test_utils_batches.py new file mode 100644 index 0000000..0157b6d --- /dev/null +++ b/server/tests/unit/jobs/test_utils_batches.py @@ -0,0 +1,27 @@ +import pytest + +from jobs.services.utils import create_batches + +pytestmark = pytest.mark.unit + + +class TestCreateBatches: + def test_empty(self): + assert create_batches([]) == [] + + def test_exact_multiple(self): + assert create_batches([1, 2, 3, 4], batch_size=2) == [[1, 2], [3, 4]] + + def test_remainder(self): + assert create_batches([1, 2, 3, 4, 5], batch_size=2) == [ + [1, 2], + [3, 4], + [5], + ] + + def test_custom_batch_size_default(self): + items = list(range(250)) + batches = create_batches(items) + assert len(batches) == 3 + assert len(batches[0]) == 100 + assert len(batches[-1]) == 50 diff --git a/server/tests/unit/services/__init__.py b/server/tests/unit/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/unit/services/test_analytics_service.py b/server/tests/unit/services/test_analytics_service.py new file mode 100644 index 0000000..630a704 --- /dev/null +++ b/server/tests/unit/services/test_analytics_service.py @@ -0,0 +1,118 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from services import analytics_service as svc + +pytestmark = pytest.mark.unit + + +class TestAnalyticsService: + def test_usage_summary(self): + latest = MagicMock(last_visit=datetime(2026, 1, 1, tzinfo=timezone.utc)) + objects = MagicMock() + objects.count.return_value = 10 + objects.return_value.count.return_value = 4 + objects.distinct.return_value = ["ES", "US"] + objects.order_by.return_value.only.return_value.first.return_value = latest + + # UserAnalytics.objects(...) and UserAnalytics.objects.count() + def objects_callable(*args, **kwargs): + m = MagicMock() + m.count.return_value = 4 + return m + + objects_callable.count = MagicMock(return_value=10) + objects_callable.distinct = MagicMock(return_value=["ES", "US"]) + objects_callable.order_by = MagicMock( + return_value=MagicMock( + only=MagicMock(return_value=MagicMock(first=MagicMock(return_value=latest))) + ) + ) + + with patch.object(svc, "UserAnalytics") as UA: + UA.objects = objects_callable + # visits_count__gte=2 path + def side_effect(*args, **kwargs): + m = MagicMock() + if kwargs.get("visits_count__gte") == 2: + m.count.return_value = 3 + else: + m.count.return_value = 4 + return m + + UA.objects = MagicMock(side_effect=side_effect) + UA.objects.count = MagicMock(return_value=10) + UA.objects.distinct = MagicMock(return_value=["ES", "US"]) + UA.objects.order_by = MagicMock( + return_value=MagicMock( + only=MagicMock( + return_value=MagicMock(first=MagicMock(return_value=latest)) + ) + ) + ) + result = svc.get_usage_summary() + + assert result["unique_users"] == 10 + assert result["countries"] == 2 + assert result["returning_pct"] == 30.0 + assert "2026-01-01" in result["as_of"] + + def test_top_countries_limit(self): + freqs = {"ES": 5, "US": 10, "FR": 1} + with ( + patch.object(svc, "UserAnalytics") as UA, + patch.object(svc, "item_frequencies", return_value=freqs), + ): + UA.objects.return_value = MagicMock() + rows = svc.get_top_countries(limit=2) + assert len(rows) == 2 + assert rows[0]["country"] == "US" + assert rows[0]["unique_users"] == 10 + + def test_capabilities_empty_and_populated(self): + with patch.object(svc, "_rollup_or_none", return_value=None): + empty = svc.get_usage_capabilities() + assert empty == {"items": [], "as_of": None} + + rollup = MagicMock( + by_capability={"search": 3, "download": 1}, + by_capability_requests={"search": 10, "download": 2}, + as_of=datetime(2026, 2, 1, tzinfo=timezone.utc), + ) + with patch.object(svc, "_rollup_or_none", return_value=rollup): + populated = svc.get_usage_capabilities() + assert len(populated["items"]) >= 1 + assert populated["items"][0]["unique_users"] >= populated["items"][-1]["unique_users"] + + def test_top_entities_empty_and_capped(self): + with patch.object(svc, "_rollup_or_none", return_value=None): + empty = svc.get_top_entities() + assert empty["top_assemblies"] == [] + + rollup = MagicMock( + top_assemblies=[{"id": str(i)} for i in range(15)], + top_annotations=[], + top_taxons=[], + as_of=None, + ) + with patch.object(svc, "_rollup_or_none", return_value=rollup): + populated = svc.get_top_entities() + assert len(populated["top_assemblies"]) == 10 + + def test_country_frequencies_and_top_visitors(self): + with ( + patch.object(svc, "UserAnalytics") as UA, + patch.object(svc, "item_frequencies", return_value={"ES": 2}), + ): + UA.objects.return_value = MagicMock() + assert svc.get_country_frequencies() == {"ES": 2} + + user = MagicMock(country="ES", visits_count=9) + qs = MagicMock() + qs.order_by.return_value.limit.return_value.only.return_value = [user] + with patch.object(svc, "UserAnalytics") as UA: + UA.objects.return_value = qs + rows = svc.get_top_visitors(limit=1) + assert rows == [{"country": "ES", "visits_count": 9}] diff --git a/server/tests/unit/services/test_annotations_service.py b/server/tests/unit/services/test_annotations_service.py new file mode 100644 index 0000000..9804972 --- /dev/null +++ b/server/tests/unit/services/test_annotations_service.py @@ -0,0 +1,180 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException +from fastapi.responses import StreamingResponse + +from services import annotations_service as svc +from tests.unit.fakes import FakeQuerySet + +pytestmark = pytest.mark.unit + + +class TestAnnotationsService: + def test_get_annotations_metadata(self): + qs = FakeQuerySet([{"annotation_id": "a"}], total=1) + with ( + patch.object(svc.annotation_helper, "get_annotation_records", return_value=qs), + ): + result = svc.get_annotations({"limit": 20, "offset": 0}) + assert result["total"] == 1 + assert len(result["results"]) == 1 + + def test_get_annotations_frequencies(self): + qs = FakeQuerySet([], total=0) + with ( + patch.object(svc.annotation_helper, "get_annotation_records", return_value=qs), + patch.object( + svc.query_visitors_helper, + "get_frequencies", + return_value={"RefSeq": 3}, + ) as freqs, + ): + result = svc.get_annotations( + {"limit": 20}, field="database", response_type="frequencies" + ) + assert result == {"RefSeq": 3} + freqs.assert_called_once() + + def test_get_annotations_tsv_branch(self): + qs = FakeQuerySet([], total=0) + stream = StreamingResponse(iter([b"x"]), media_type="text/tab-separated-values") + with ( + patch.object(svc.annotation_helper, "get_annotation_records", return_value=qs), + patch.object(svc, "stream_annotation_tsv", return_value=stream) as tsv, + ): + result = svc.get_annotations( + {"selected_fields": "busco_complete"}, response_type="tsv" + ) + assert result is stream + tsv.assert_called_once() + + @pytest.mark.asyncio + async def test_stream_annotation_tsv_joins_assembly_fields(self): + qs = FakeQuerySet([["md5", "GCA_1"]], total=1) + field_map = {"annotation_id": "annotation_id", "assembly_accession": "assembly_accession"} + assembly_map = {"assembly_gc_percent": "gc_percent"} + + def fake_iter(_annotations, _paths, batch_size=5000): + yield ["md5", "GCA_1"] + + with ( + patch.object(svc.tsv_fields_helper, "resolve_tsv_field_map", return_value=field_map), + patch.object( + svc.tsv_fields_helper, + "resolve_assembly_tsv_field_map", + return_value=assembly_map, + ), + patch.object(svc.tsv_fields_helper, "iter_tsv_rows", side_effect=fake_iter), + patch.object( + svc.tsv_fields_helper, + "resolve_assembly_rows", + return_value=[["42.0"]], + ) as join, + patch.object( + svc.tsv_fields_helper, + "format_tsv_cell", + side_effect=lambda v, extended=False: str(v if v is not None else ""), + ), + ): + resp = svc.stream_annotation_tsv(qs, selected_fields="assembly_gc_percent") + assert isinstance(resp, StreamingResponse) + async for _ in resp.body_iterator: + pass + join.assert_called_once() + + def test_get_annotation_404(self): + with patch.object(svc, "GenomeAnnotation") as GA: + GA.objects.return_value = FakeQuerySet([]) + with pytest.raises(HTTPException) as ctx: + svc.get_annotation("missing") + assert ctx.value.status_code == 404 + + def test_get_annotation_metadata_happy(self): + ann = MagicMock() + ann.to_mongo.return_value.to_dict.return_value = {"annotation_id": "md5"} + with patch.object(svc, "GenomeAnnotation") as GA: + GA.objects.return_value = FakeQuerySet([ann]) + result = svc.get_annotation_metadata("md5") + assert result["annotation_id"] == "md5" + + def test_tabix_no_filters_400(self): + ann = MagicMock() + with ( + patch.object(svc, "get_annotation", return_value=ann), + patch.object(svc.file_helper, "get_annotation_file_path", return_value="/a.gff.gz"), + patch.object(svc.os.path, "exists", return_value=True), + ): + with pytest.raises(HTTPException) as ctx: + svc.stream_annotation_tabix("md5") + assert ctx.value.status_code == 400 + + def test_tabix_invalid_biotype_400(self): + ann = MagicMock() + ann.features_summary.biotypes = ["protein_coding"] + ann.features_summary.types = ["gene"] + ann.features_summary.sources = ["RefSeq"] + with ( + patch.object(svc, "get_annotation", return_value=ann), + patch.object(svc.file_helper, "get_annotation_file_path", return_value="/a.gff.gz"), + patch.object(svc.os.path, "exists", return_value=True), + ): + with pytest.raises(HTTPException) as ctx: + svc.stream_annotation_tabix("md5", biotype="nope") + assert ctx.value.status_code == 400 + + def test_tabix_missing_file_404(self): + ann = MagicMock() + with ( + patch.object(svc, "get_annotation", return_value=ann), + patch.object(svc.file_helper, "get_annotation_file_path", return_value="/missing"), + patch.object(svc.os.path, "exists", return_value=False), + ): + with pytest.raises(HTTPException) as ctx: + svc.stream_annotation_tabix("md5", feature_type="gene") + assert ctx.value.status_code == 404 + + def test_gene_busco_summary_smoke(self): + qs = FakeQuerySet([], total=0) + with ( + patch.object(svc.params_helper, "handle_request_params", return_value={}), + patch.object(svc.annotation_helper, "get_annotation_records", return_value=qs), + patch.object( + svc.feature_stats_helper, + "get_gene_stats_summary", + return_value={"total_annotations": 0}, + ) as gene, + patch.object( + svc.busco_stats_helper, + "get_busco_stats_summary", + return_value={"total_annotations": 0}, + ) as busco, + ): + assert svc.get_gene_stats_summary()["total_annotations"] == 0 + assert svc.get_busco_stats_summary()["total_annotations"] == 0 + gene.assert_called_once_with(qs) + busco.assert_called_once_with(qs) + + def test_aggregates_by_taxon_rank(self): + docs = [ + { + "taxid": "9606", + "taxon_name": "Homo sapiens", + "annotations_count": 2, + "avg_coding_genes_count": 1, + "avg_non_coding_genes_count": 1, + "avg_pseudogenes_count": 0, + } + ] + with ( + patch.object(svc, "GenomeAnnotation") as GA, + patch.object( + svc.pipelines_helper, + "aggregate_by_taxon_pipeline", + return_value=[{"$match": {}}], + ), + ): + GA.objects.aggregate.return_value = docs + result = svc.get_annotations_aggregates_by_taxon_rank("species") + assert result["fields"][0] == "taxid" + assert result["rows"][0][0] == "9606" diff --git a/server/tests/unit/services/test_assemblies_service.py b/server/tests/unit/services/test_assemblies_service.py new file mode 100644 index 0000000..2cd9f4a --- /dev/null +++ b/server/tests/unit/services/test_assemblies_service.py @@ -0,0 +1,91 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException +from fastapi.responses import FileResponse + +from services import assemblies_service as svc +from tests.unit.fakes import FakeQuerySet + +pytestmark = pytest.mark.unit + + +class TestAssembliesService: + def test_list_pagination(self): + qs = FakeQuerySet([{"assembly_accession": "GCA_1"}], total=1) + with patch.object(svc, "GenomeAssembly") as GA: + GA.objects.return_value = qs + result = svc.get_assemblies(offset=0, limit=20) + assert result["total"] == 1 + assert len(result["results"]) == 1 + + def test_invalid_report_status_400(self): + qs = FakeQuerySet([]) + with patch.object(svc, "GenomeAssembly") as GA: + GA.objects.return_value = qs + with pytest.raises(HTTPException) as ctx: + svc.get_assemblies(report_status="nope") + assert ctx.value.status_code == 400 + + def test_frequencies_requires_field(self): + qs = FakeQuerySet([]) + with patch.object(svc, "GenomeAssembly") as GA: + GA.objects.return_value = qs + with pytest.raises(HTTPException) as ctx: + svc.get_assemblies(response_type="frequencies") + assert ctx.value.status_code == 400 + + def test_get_404(self): + qs = FakeQuerySet([]) + with patch.object(svc, "GenomeAssembly") as GA: + GA.objects.return_value = qs + with pytest.raises(HTTPException) as ctx: + svc.get_assembly("missing") + assert ctx.value.status_code == 404 + + def test_paired_missing_404(self): + asm = MagicMock(paired_assembly_accession=None) + with patch.object(svc, "get_assembly", return_value=asm): + with pytest.raises(HTTPException) as ctx: + svc.get_paired_assembly("GCA_1") + assert ctx.value.status_code == 404 + + def test_paired_happy(self): + primary = MagicMock(paired_assembly_accession="GCF_1") + paired = MagicMock(assembly_accession="GCF_1") + with patch.object(svc, "get_assembly", side_effect=[primary, paired]): + result = svc.get_paired_assembly("GCA_1") + assert result is paired + + def test_chromosomes_file_missing(self): + asm = MagicMock(taxid="9606", paired_assembly_accession=None) + with ( + patch.object(svc, "get_assembly", return_value=asm), + patch.object(svc.seq_files, "resolve_chromosomes_path", return_value="/x.json"), + patch.object(svc.os.path, "isfile", return_value=False), + ): + with pytest.raises(HTTPException) as ctx: + svc.get_chromosomes_file("GCA_1") + assert ctx.value.status_code == 404 + + def test_chromosomes_file_ok(self, tmp_path): + path = tmp_path / "chromosomes.json" + path.write_text("[]") + asm = MagicMock(taxid="9606", paired_assembly_accession=None) + with ( + patch.object(svc, "get_assembly", return_value=asm), + patch.object(svc.seq_files, "resolve_chromosomes_path", return_value=str(path)), + patch.object(svc.os.path, "isfile", return_value=True), + ): + result = svc.get_chromosomes_file("GCA_1") + assert isinstance(result, FileResponse) + + def test_chr_aliases_file_missing(self): + asm = MagicMock(taxid="9606", paired_assembly_accession=None) + with ( + patch.object(svc, "get_assembly", return_value=asm), + patch.object(svc.seq_files, "resolve_chr_aliases_path", return_value=None), + ): + with pytest.raises(HTTPException) as ctx: + svc.get_chr_aliases_file("GCA_1") + assert ctx.value.status_code == 404 diff --git a/server/tests/unit/services/test_bioproject_service.py b/server/tests/unit/services/test_bioproject_service.py new file mode 100644 index 0000000..3397005 --- /dev/null +++ b/server/tests/unit/services/test_bioproject_service.py @@ -0,0 +1,33 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException + +from services import bioproject_service +from tests.unit.fakes import FakeQuerySet + +pytestmark = pytest.mark.unit + + +class TestBioprojectService: + def test_list_pagination_shape(self): + items = [MagicMock(accession="PRJNA1")] + qs = FakeQuerySet(items, total=1) + with patch("services.bioproject_service.BioProject.objects", return_value=qs): + result = bioproject_service.get_bioprojects(offset=0, limit=10) + assert result["total"] == 1 + assert len(result["results"]) == 1 + + def test_get_happy(self): + bp = MagicMock(accession="PRJNA1") + qs = FakeQuerySet([bp]) + with patch("services.bioproject_service.BioProject.objects", return_value=qs): + result = bioproject_service.get_bioproject("PRJNA1") + assert result is bp + + def test_get_404(self): + qs = FakeQuerySet([]) + with patch("services.bioproject_service.BioProject.objects", return_value=qs): + with pytest.raises(HTTPException) as ctx: + bioproject_service.get_bioproject("missing") + assert ctx.value.status_code == 404 diff --git a/server/tests/unit/services/test_jobs_service_auth.py b/server/tests/unit/services/test_jobs_service_auth.py new file mode 100644 index 0000000..40d0c8b --- /dev/null +++ b/server/tests/unit/services/test_jobs_service_auth.py @@ -0,0 +1,111 @@ +import os +from unittest.mock import patch + +import pytest +from fastapi import HTTPException + +from services import jobs_service + +pytestmark = pytest.mark.unit + +# Already covered in B1: export_flattened_taxonomy, track_unique_users_by_country +REMAINING_SIMPLE_TRIGGERS = [ + ("trigger_update_records", "services.jobs_service.update_records.delay"), + ("trigger_import_annotations", "services.jobs_service.import_annotations.delay"), + ("trigger_update_taxonomy_stats", "services.jobs_service.update_taxon_stats.delay"), + ("trigger_backfill_taxon_parent_id", "services.jobs_service.backfill_taxon_parent_id.delay"), + ("trigger_update_busco_scores", "services.jobs_service.update_busco_scores.delay"), + ( + "trigger_update_taxons_busco_scores", + "services.jobs_service.update_taxons_busco_scores_job.delay", + ), + ( + "trigger_remap_all_assemblies_and_annotations", + "services.jobs_service.remap_all_assemblies_and_annotations.delay", + ), + ( + "trigger_backfill_placeholder_assembly_download_urls", + "services.jobs_service.backfill_placeholder_assembly_download_urls.delay", + ), +] + + +class TestJobsServiceAuth: + def test_wrong_key_raises_401(self): + with patch.dict(os.environ, {"AUTH_KEY": "secret"}): + with pytest.raises(HTTPException) as ctx: + jobs_service.trigger_export_flattened_taxonomy("wrong") + assert ctx.value.status_code == 401 + + def test_empty_key_raises_401_when_expected_set(self): + with patch.dict(os.environ, {"AUTH_KEY": "secret"}): + with pytest.raises(HTTPException) as ctx: + jobs_service.trigger_export_flattened_taxonomy("") + assert ctx.value.status_code == 401 + + def test_valid_key_triggers_delay(self): + with ( + patch.dict(os.environ, {"AUTH_KEY": "secret"}), + patch("services.jobs_service.export_flattened_taxonomy.delay") as delay, + ): + result = jobs_service.trigger_export_flattened_taxonomy("secret") + delay.assert_called_once_with() + assert "message" in result + + def test_second_trigger_smoke(self): + with ( + patch.dict(os.environ, {"AUTH_KEY": "secret"}), + patch("services.jobs_service.track_unique_users_by_country.delay") as delay, + ): + result = jobs_service.trigger_track_unique_users_by_country("secret") + delay.assert_called_once_with() + assert "message" in result + + @pytest.mark.parametrize("fn_name,delay_path", REMAINING_SIMPLE_TRIGGERS) + def test_remaining_triggers_auth_and_delay(self, fn_name, delay_path): + fn = getattr(jobs_service, fn_name) + with patch.dict(os.environ, {"AUTH_KEY": "secret"}): + with pytest.raises(HTTPException) as ctx: + fn("wrong") + assert ctx.value.status_code == 401 + + with patch(delay_path) as delay: + result = fn("secret") + delay.assert_called_once() + assert "message" in result + + def test_prune_passes_dry_run(self): + with ( + patch.dict(os.environ, {"AUTH_KEY": "secret"}), + patch( + "services.jobs_service.prune_annotations_missing_source_url.delay" + ) as delay, + ): + jobs_service.trigger_prune_annotations_missing_source_url( + "secret", dry_run=False + ) + delay.assert_called_once_with(dry_run=False) + + def test_unset_passes_dry_run(self): + with ( + patch.dict(os.environ, {"AUTH_KEY": "secret"}), + patch( + "services.jobs_service.unset_genome_annotation_mapped_regions_task.delay" + ) as delay, + ): + jobs_service.trigger_unset_genome_annotation_mapped_regions( + "secret", dry_run=True + ) + delay.assert_called_once_with(dry_run=True) + + def test_sync_passes_accessions(self): + with ( + patch.dict(os.environ, {"AUTH_KEY": "secret"}), + patch( + "services.jobs_service.sync_new_assemblies_from_summary.delay" + ) as delay, + ): + jobs_service.trigger_sync_new_assemblies_from_summary( + "secret", accessions=["GCA_1"] + ) + delay.assert_called_once_with(accessions=["GCA_1"]) diff --git a/server/tests/unit/services/test_organism_service.py b/server/tests/unit/services/test_organism_service.py new file mode 100644 index 0000000..a8f8b7b --- /dev/null +++ b/server/tests/unit/services/test_organism_service.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException + +from services import organism_service +from tests.unit.fakes import FakeQuerySet + +pytestmark = pytest.mark.unit + + +class TestOrganismService: + def test_list_pagination_shape(self): + items = [MagicMock(taxid="9606"), MagicMock(taxid="10090")] + qs = FakeQuerySet(items, total=2) + with patch("services.organism_service.Organism.objects", return_value=qs): + result = organism_service.get_organisms(offset=0, limit=20) + assert result["total"] == 2 + assert result["offset"] == 0 + assert result["limit"] == 20 + assert len(result["results"]) == 2 + + def test_get_happy(self): + org = MagicMock(taxid="9606") + qs = FakeQuerySet([org]) + with patch("services.organism_service.Organism.objects", return_value=qs): + result = organism_service.get_organism("9606") + assert result is org + + def test_get_404(self): + qs = FakeQuerySet([]) + with patch("services.organism_service.Organism.objects", return_value=qs): + with pytest.raises(HTTPException) as ctx: + organism_service.get_organism("missing") + assert ctx.value.status_code == 404 diff --git a/server/tests/unit/services/test_taxonomy_service.py b/server/tests/unit/services/test_taxonomy_service.py new file mode 100644 index 0000000..e831ca9 --- /dev/null +++ b/server/tests/unit/services/test_taxonomy_service.py @@ -0,0 +1,132 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException +from fastapi.responses import RedirectResponse, StreamingResponse + +from services import taxonomy_service as svc +from tests.unit.fakes import FakeQuerySet + +pytestmark = pytest.mark.unit + + +class _QsKeepsSelf(FakeQuerySet): + """as_pymongo returns self so service paths that call .count() after as_pymongo work.""" + + def as_pymongo(self): + return self + + def __len__(self): + return self._total + + +class TestTaxonomyService: + def test_list_uses_pagination_helper(self): + qs = _QsKeepsSelf([{"taxid": "9606"}], total=1) + with patch.object(svc, "TaxonNode") as TN: + TN.objects.return_value = qs + result = svc.get_taxon_nodes(offset=0, limit=20) + assert result["total"] == 1 + assert result["results"] == [{"taxid": "9606"}] + + def test_get_happy_and_404(self): + node = MagicMock(taxid="9606") + with patch.object(svc, "TaxonNode") as TN: + TN.objects.return_value = FakeQuerySet([node]) + assert svc.get_taxon_node("9606") is node + TN.objects.return_value = FakeQuerySet([]) + with pytest.raises(HTTPException) as ctx: + svc.get_taxon_node("missing") + assert ctx.value.status_code == 404 + + def test_children(self): + parent = {"children": ["1", "2"]} + children_qs = _QsKeepsSelf([{"taxid": "1"}, {"taxid": "2"}], total=2) + with ( + patch.object(svc, "get_taxon_node", return_value=parent), + patch.object(svc, "TaxonNode") as TN, + ): + TN.objects.return_value = children_qs + result = svc.get_taxon_node_children("9606") + assert result["total"] == 2 + + def test_ancestors(self): + leaf = MagicMock() + leaf.to_mongo.return_value.to_dict.return_value = {"taxid": "9606"} + leaf.taxid = "9606" + parent = MagicMock() + parent.to_mongo.return_value.to_dict.return_value = {"taxid": "1"} + parent.taxid = "1" + + calls = {"n": 0} + + def objects_side_effect(**kwargs): + calls["n"] += 1 + if "children" in kwargs: + if calls["n"] == 1: + return FakeQuerySet([parent]) + return FakeQuerySet([]) + return FakeQuerySet([leaf]) + + with ( + patch.object(svc, "get_taxon_node", return_value=leaf), + patch.object(svc, "TaxonNode") as TN, + ): + TN.objects.side_effect = objects_side_effect + result = svc.get_ancestors("9606") + assert result["total"] == 2 + assert result["results"][0]["taxid"] == "1" + assert result["results"][1]["taxid"] == "9606" + + def test_flattened_tree_prebuilt_redirect(self): + redirect = RedirectResponse(url="/files/x", status_code=307) + with patch.object(svc, "_get_prebuilt_flattened_tree_response", return_value=redirect): + result = svc.get_flattened_tree("json") + assert result is redirect + assert result.status_code == 307 + + def test_flattened_tree_json_fallback(self): + coll = MagicMock() + coll.find.return_value = [] + coll.aggregate.return_value = [ + { + "taxid": "9606", + "parent_id": "9605", + "scientific_name": "Homo sapiens", + "annotations_count": 1, + "assemblies_count": 1, + "organisms_count": 1, + "rank": "species", + } + ] + with ( + patch.object(svc, "_get_prebuilt_flattened_tree_response", return_value=None), + patch.object(svc, "TaxonNode") as TN, + ): + TN._get_collection.return_value = coll + result = svc.get_flattened_tree("json") + assert "fields" in result + assert len(result["rows"]) == 1 + assert result["rows"][0][0] == "9606" + + def test_flattened_tree_tsv_fallback(self): + coll = MagicMock() + coll.find.return_value = [] + coll.aggregate.return_value = [ + { + "taxid": "1", + "parent_id": None, + "scientific_name": "Root", + "annotations_count": 0, + "assemblies_count": 0, + "organisms_count": 0, + "rank": "no rank", + } + ] + with ( + patch.object(svc, "_get_prebuilt_flattened_tree_response", return_value=None), + patch.object(svc, "TaxonNode") as TN, + ): + TN._get_collection.return_value = coll + result = svc.get_flattened_tree("tsv") + assert isinstance(result, StreamingResponse) diff --git a/server/tests/unit/services/test_upload_gff_validation.py b/server/tests/unit/services/test_upload_gff_validation.py new file mode 100644 index 0000000..0ea8180 --- /dev/null +++ b/server/tests/unit/services/test_upload_gff_validation.py @@ -0,0 +1,163 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +from services import upload_gff_service as upload_svc + +pytestmark = pytest.mark.unit + + +class TestValidateExtension: + @pytest.mark.parametrize( + "filename", + ["a.gff", "a.gff3", "a.GFF.GZ", "ann.gff3.gz"], + ) + def test_accepts_allowed(self, filename): + upload_svc._validate_extension(filename) + + @pytest.mark.parametrize( + "filename", + ["a.txt", "a.gff.bz2", "noext", ""], + ) + def test_rejects_disallowed(self, filename): + with pytest.raises(HTTPException) as ctx: + upload_svc._validate_extension(filename) + assert ctx.value.status_code == 400 + + +class TestRateLimit: + def test_status_under_limit(self): + qs = MagicMock() + qs.count.return_value = 3 + with ( + patch.object(upload_svc.UploadRateLimit, "objects", return_value=qs), + patch.object(upload_svc.settings, "UPLOAD_DAILY_LIMIT", 10), + ): + status = upload_svc.get_rate_limit_status("1.2.3.4", "ua") + assert status == {"used": 3, "remaining": 7} + + def test_enforce_under_limit(self): + qs = MagicMock() + qs.count.return_value = 2 + with ( + patch.object(upload_svc.UploadRateLimit, "objects", return_value=qs), + patch.object(upload_svc.settings, "UPLOAD_DAILY_LIMIT", 5), + ): + used, remaining = upload_svc._enforce_rate_limit("1.2.3.4", "ua") + assert used == 3 + assert remaining == 2 + + def test_enforce_raises_429_when_exhausted(self): + qs = MagicMock() + qs.count.return_value = 5 + with ( + patch.object(upload_svc.UploadRateLimit, "objects", return_value=qs), + patch.object(upload_svc.settings, "UPLOAD_DAILY_LIMIT", 5), + ): + with pytest.raises(HTTPException) as ctx: + upload_svc._enforce_rate_limit("1.2.3.4", "ua") + assert ctx.value.status_code == 429 + assert ctx.value.detail["remaining"] == 0 + + +def _upload_file(filename: str, chunks: list[bytes]) -> MagicMock: + upload = MagicMock() + upload.filename = filename + upload.read = AsyncMock(side_effect=chunks + [b""]) + return upload + + +class TestEnqueueUploadGffJob: + @pytest.mark.asyncio + async def test_happy_path(self, tmp_path, monkeypatch): + monkeypatch.setenv("LOCAL_ANNOTATIONS_DIR", str(tmp_path)) + rate_doc = MagicMock() + task = MagicMock(id="task-123") + upload = _upload_file("ann.gff3", [b"##gff-version 3\n"]) + + with ( + patch.object( + upload_svc, "_enforce_rate_limit", return_value=(2, 8) + ), + patch.object( + upload_svc, + "_write_temp_file", + new=AsyncMock(return_value=str(tmp_path / "ann.gff3")), + ), + patch.object(upload_svc, "UploadRateLimit", return_value=rate_doc), + patch( + "services.upload_gff_service.compute_custom_gff_stats.delay", + return_value=task, + ) as delay, + ): + result = await upload_svc.enqueue_upload_gff_job( + "1.2.3.4", "ua", upload, "My upload" + ) + + assert result == {"task_id": "task-123", "remaining_quota": 8} + delay.assert_called_once() + assert rate_doc.save.call_count >= 2 + assert rate_doc.task_id == "task-123" + + @pytest.mark.asyncio + async def test_blank_custom_name(self): + upload = _upload_file("ann.gff3", [b"x"]) + with pytest.raises(HTTPException) as ctx: + await upload_svc.enqueue_upload_gff_job("ip", "ua", upload, " ") + assert ctx.value.status_code == 400 + + @pytest.mark.asyncio + async def test_empty_file(self, tmp_path, monkeypatch): + monkeypatch.setenv("LOCAL_ANNOTATIONS_DIR", str(tmp_path)) + qs = MagicMock() + qs.count.return_value = 0 + upload = _upload_file("ann.gff3", []) + with ( + patch.object(upload_svc.settings, "UPLOAD_DAILY_LIMIT", 10), + patch.object(upload_svc.settings, "UPLOAD_MAX_BYTES", 1024), + patch.object(upload_svc.UploadRateLimit, "objects", return_value=qs), + patch( + "services.upload_gff_service.compute_custom_gff_stats.delay" + ) as delay, + ): + with pytest.raises(HTTPException) as ctx: + await upload_svc.enqueue_upload_gff_job("ip", "ua", upload, "Name") + assert ctx.value.status_code == 400 + delay.assert_not_called() + + @pytest.mark.asyncio + async def test_oversize_413(self, tmp_path, monkeypatch): + monkeypatch.setenv("LOCAL_ANNOTATIONS_DIR", str(tmp_path)) + qs = MagicMock() + qs.count.return_value = 0 + upload = _upload_file("ann.gff3", [b"abcdefghij"]) + with ( + patch.object(upload_svc.settings, "UPLOAD_DAILY_LIMIT", 10), + patch.object(upload_svc.settings, "UPLOAD_MAX_BYTES", 5), + patch.object(upload_svc.UploadRateLimit, "objects", return_value=qs), + patch( + "services.upload_gff_service.compute_custom_gff_stats.delay" + ) as delay, + ): + with pytest.raises(HTTPException) as ctx: + await upload_svc.enqueue_upload_gff_job("ip", "ua", upload, "Name") + assert ctx.value.status_code == 413 + delay.assert_not_called() + + @pytest.mark.asyncio + async def test_rate_exhausted_429(self): + qs = MagicMock() + qs.count.return_value = 5 + upload = _upload_file("ann.gff3", [b"x"]) + with ( + patch.object(upload_svc.settings, "UPLOAD_DAILY_LIMIT", 5), + patch.object(upload_svc.UploadRateLimit, "objects", return_value=qs), + patch( + "services.upload_gff_service.compute_custom_gff_stats.delay" + ) as delay, + ): + with pytest.raises(HTTPException) as ctx: + await upload_svc.enqueue_upload_gff_job("ip", "ua", upload, "Name") + assert ctx.value.status_code == 429 + delay.assert_not_called() diff --git a/server/tests/unit/test_health.py b/server/tests/unit/test_health.py new file mode 100644 index 0000000..edaea74 --- /dev/null +++ b/server/tests/unit/test_health.py @@ -0,0 +1,9 @@ +import pytest + +pytestmark = pytest.mark.unit + + +def test_health_ok(client): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} From 6989e6de491b0b50f7298de69906a33b0edde296 Mon Sep 17 00:00:00 2001 From: Emilio Righi Date: Wed, 12 Aug 2026 10:08:52 +0200 Subject: [PATCH 3/5] fix compiilation errors --- front/lib/annotation-display.test.ts | 9 +++++- front/lib/test/mock-fetch.ts | 45 ++++++++++------------------ 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/front/lib/annotation-display.test.ts b/front/lib/annotation-display.test.ts index 9e64db7..b3865c4 100644 --- a/front/lib/annotation-display.test.ts +++ b/front/lib/annotation-display.test.ts @@ -22,13 +22,20 @@ function portal(id: string, name = "Org"): PortalAnnotation { assembly_name: "asm", source_file_info: { database: "RefSeq", - url: "https://example.com", + provider: "NCBI", last_modified: "2020-01-01", + uncompressed_md5: id, + pipeline: { name: "x", version: "1", method: "m" }, + release_date: "2020-01-01", + source_database: "RefSeq", }, indexed_file_info: { uncompressed_md5: id, file_size: 1, bgzipped_path: "/x", + csi_path: "/x.csi", + processed_at: "2020-01-01T00:00:00Z", + pipeline: { name: "x", version: "1", method: "m" }, }, features_summary: summary, } diff --git a/front/lib/test/mock-fetch.ts b/front/lib/test/mock-fetch.ts index a02544f..6292406 100644 --- a/front/lib/test/mock-fetch.ts +++ b/front/lib/test/mock-fetch.ts @@ -34,47 +34,34 @@ export function mockJsonResponse( body: unknown, init?: { status?: number; headers?: HeadersInit }, ): Response { - const status = init?.status ?? 200 - return { - ok: status >= 200 && status < 300, - status, - headers: new Headers(init?.headers), - json: async () => body, - blob: async () => new Blob([JSON.stringify(body)]), - text: async () => JSON.stringify(body), - } as Response + const headers = new Headers(init?.headers) + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json") + } + return new Response(JSON.stringify(body), { + status: init?.status ?? 200, + headers, + }) } export function mockBlobResponse( blob: Blob, init?: { status?: number; headers?: HeadersInit }, ): Response { - const status = init?.status ?? 200 - return { - ok: status >= 200 && status < 300, - status, - headers: new Headers(init?.headers), - json: async () => { - throw new Error("mockBlobResponse: json() not available") - }, - blob: async () => blob, - text: async () => blob.text(), - } as Response + return new Response(blob, { + status: init?.status ?? 200, + headers: init?.headers, + }) } export function mockTextResponse( text: string, init?: { status?: number; headers?: HeadersInit }, ): Response { - const status = init?.status ?? 200 - return { - ok: status >= 200 && status < 300, - status, - headers: new Headers(init?.headers), - json: async () => JSON.parse(text), - blob: async () => new Blob([text]), - text: async () => text, - } as Response + return new Response(text, { + status: init?.status ?? 200, + headers: init?.headers, + }) } export function getFetchCalls(): FetchCall[] { From 10da2727c3947ee0d7b1a3357d1f927e671e88e6 Mon Sep 17 00:00:00 2001 From: Emilio Righi Date: Wed, 12 Aug 2026 11:15:44 +0200 Subject: [PATCH 4/5] move download tsv UI in right sidebar --- front/app/annotations/page.tsx | 17 +- .../annotations/download-tsv-dialog.tsx | 244 ------------------ .../components/sidebar/download-tsv-panel.tsx | 228 ++++++++++++++++ front/components/sidebar/right-sidebar.tsx | 51 ++-- front/lib/stores/ui.ts | 4 +- 5 files changed, 259 insertions(+), 285 deletions(-) delete mode 100644 front/components/annotations/download-tsv-dialog.tsx create mode 100644 front/components/sidebar/download-tsv-panel.tsx diff --git a/front/app/annotations/page.tsx b/front/app/annotations/page.tsx index 2f33fec..b92d8fc 100644 --- a/front/app/annotations/page.tsx +++ b/front/app/annotations/page.tsx @@ -21,7 +21,6 @@ import { listAnnotations, type FetchAnnotationsParams } from "@/lib/api/annotati import type { AnnotationRecord } from "@/lib/api/types" import { RightSidebar } from "@/components/sidebar/right-sidebar" import { FavoritesFloatingButton } from "@/components/layout/favorites-floating-button" -import { DownloadTsvDialog } from "@/components/annotations/download-tsv-dialog" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -84,7 +83,6 @@ function AnnotationsContent() { const [annotations, setAnnotations] = useState([]) const [totalAnnotations, setTotalAnnotations] = useState(0) const [loading, setLoading] = useState(true) - const [reportOpen, setReportOpen] = useState(false) const sidebarRef = useRef(null) const hasInitializedRef = useRef(false) const fetchRequestIdRef = useRef(0) @@ -109,6 +107,10 @@ function AnnotationsContent() { return buildAnnotationsParams(false, []) as FetchAnnotationsParams }, [buildAnnotationsParams]) + const handleDownloadTsv = useCallback(() => { + openRightSidebar("download-tsv", { totalAnnotations, buildDownloadParams }) + }, [openRightSidebar, totalAnnotations, buildDownloadParams]) + // ── Redirect legacy entity query params ─────────────────────────────────── useEffect(() => { const taxonParam = searchParams?.get("taxon") @@ -307,7 +309,7 @@ function AnnotationsContent() { Browse Assemblies - setReportOpen(true)}> + Download TSV @@ -336,7 +338,7 @@ function AnnotationsContent() { - - - - - ) -} diff --git a/front/components/sidebar/download-tsv-panel.tsx b/front/components/sidebar/download-tsv-panel.tsx new file mode 100644 index 0000000..cb83b5f --- /dev/null +++ b/front/components/sidebar/download-tsv-panel.tsx @@ -0,0 +1,228 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" +import { + downloadAnnotationsReport, + type FetchAnnotationsParams, +} from "@/lib/api/annotations" +import { + buildSelectedFieldsParam, + getAssemblyTsvFields, + getDefaultTsvFields, + getExtendedTsvFields, +} from "@/lib/annotations-tsv-fields" +import { useUIStore } from "@/lib/stores/ui" + +interface DownloadTsvPanelProps { + totalAnnotations: number + buildDownloadParams: () => FetchAnnotationsParams +} + +export function DownloadTsvPanel({ + totalAnnotations, + buildDownloadParams, +}: DownloadTsvPanelProps) { + const closeRightSidebar = useUIStore((state) => state.closeRightSidebar) + const [loading, setLoading] = useState(false) + const [checkedExtended, setCheckedExtended] = useState>(new Set()) + + const defaultFields = useMemo(() => getDefaultTsvFields(), []) + const extendedFields = useMemo(() => getExtendedTsvFields(), []) + const assemblyFields = useMemo(() => getAssemblyTsvFields(), []) + const additionalCount = checkedExtended.size + const totalColumnCount = defaultFields.length + additionalCount + + useEffect(() => { + setCheckedExtended(new Set()) + }, []) + + const toggleExtendedField = useCallback((key: string, checked: boolean) => { + setCheckedExtended((prev) => { + const next = new Set(prev) + if (checked) { + next.add(key) + } else { + next.delete(key) + } + return next + }) + }, []) + + const handleDownload = useCallback(async () => { + try { + setLoading(true) + const params = { ...buildDownloadParams() } + delete params.limit + delete params.offset + + const selectedFields = buildSelectedFieldsParam(checkedExtended) + if (selectedFields) { + params.selected_fields = selectedFields + } + + const blob = await downloadAnnotationsReport(params) + const url = window.URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = "annotations_report.tsv" + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.URL.revokeObjectURL(url) + closeRightSidebar() + } catch (error) { + console.error(error) + } finally { + setLoading(false) + } + }, [buildDownloadParams, checkedExtended, closeRightSidebar]) + + return ( +
    +
    +

    + Generate a TSV report of the current annotation results based on your active filters. +

    + +
    +
    Default columns (always included)
    +
    + {defaultFields.map((field) => ( + + {field.key} + + ))} +
    +
    + +
    +
    Additional columns
    +

    + Select extra fields to append after the default columns. +

    +
    +
    + {extendedFields.map((field) => { + const checkboxId = `tsv-field-${field.key}` + return ( +
    + + toggleExtendedField(field.key, value === true) + } + disabled={loading} + /> + +
    + ) + })} +
    +
    +
    + +
    +
    Assembly fields
    +

    + Resolved from the parent genome assembly record (joined on assembly accession), + so you can get both the GFF and the FASTA download link in one TSV. +

    +
    + {assemblyFields.map((field) => { + const checkboxId = `tsv-field-${field.key}` + return ( +
    + + toggleExtendedField(field.key, value === true) + } + disabled={loading} + /> + +
    + ) + })} +
    +
    + +
    +
    Summary
    +
      +
    • + Total annotations in current result set:{" "} + + {totalAnnotations.toLocaleString()} + +
    • +
    • + Columns to export:{" "} + {totalColumnCount}{" "} + ({defaultFields.length} default + {additionalCount > 0 ? ` + ${additionalCount} additional` : ""}) +
    • +
    +
    + +
    +
    About file URLs in the report
    +
      +
    • + source_url: direct link to + the original source file provided by the data source. +
    • +
    • + bgzip_path/csi_path: relative path of + the file processed by Annotrieve (sorted, bgzipped, and indexed). To download, + prepend{" "} + + https://genome.crg.es/annotrieve/files + {" "} + to this path. +
    • +
    • + assembly_download_url: direct + link to the genome assembly FASTA file, resolved from the assembly record. Left + empty when the URL is not yet resolved. +
    • +
    +
    +
    + +
    + + +
    +
    + ) +} diff --git a/front/components/sidebar/right-sidebar.tsx b/front/components/sidebar/right-sidebar.tsx index ef6b68c..877f07e 100644 --- a/front/components/sidebar/right-sidebar.tsx +++ b/front/components/sidebar/right-sidebar.tsx @@ -3,9 +3,9 @@ import { useUIStore } from "@/lib/stores/ui" import { FileOverviewSidebar } from "./file-overview-dialog" import { AssembliesListTable } from "./assemblies-list-table" +import { DownloadTsvPanel } from "./download-tsv-panel" import { Button } from "@/components/ui/button" import { X } from "lucide-react" -import { cn } from "@/lib/utils" import { useCallback, useEffect } from "react" import { usePathname, useRouter, useSearchParams } from "next/navigation" import { @@ -13,6 +13,17 @@ import { isAnnotationsListPath, } from "@/lib/hooks/use-annotation-overview-url-sync" +function shellTitle(view: string): string { + switch (view) { + case "assemblies-list": + return "Assemblies List" + case "download-tsv": + return "Download TSV report" + default: + return "Details" + } +} + export function RightSidebar() { const rightSidebar = useUIStore((state) => state.rightSidebar) const closeRightSidebar = useUIStore((state) => state.closeRightSidebar) @@ -37,7 +48,6 @@ export function RightSidebar() { closeRightSidebar() }, [view, closeFileOverview, closeRightSidebar]) - // Close on escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && isOpen) { @@ -50,18 +60,7 @@ export function RightSidebar() { if (!isOpen || !view) return null - const getTitle = () => { - switch (view) { - case "file-overview": - return "Annotation Overview" - case "assemblies-list": - return "Assemblies List" - default: - return "Details" - } - } - - // FileOverviewSidebar has its own overlay and structure, so handle it separately + // FileOverviewSidebar owns its own overlay/panel. if (view === "file-overview" && data.annotation) { return ( - {/* Overlay */}
    - {/* Sidebar */}
    - {/* Header - skip for taxon-details (TaxonDetailsSidebar renders its own) */}
    -

    {getTitle()}

    +

    {shellTitle(view)}

    - {/* Content */}
    {view === "assemblies-list" && (
    )} + {view === "download-tsv" && ( + ({}))} + /> + )}
    diff --git a/front/lib/stores/ui.ts b/front/lib/stores/ui.ts index 9931f68..92db487 100644 --- a/front/lib/stores/ui.ts +++ b/front/lib/stores/ui.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -export type RightSidebarView = "file-overview" | "taxonomic-tree" | "assemblies-list" | "taxon-details" | null +export type RightSidebarView = "file-overview" | "taxonomic-tree" | "assemblies-list" | "taxon-details" | "download-tsv" | null export type Theme = 'light' | 'dark' @@ -22,6 +22,8 @@ interface UIState { annotation?: any // For file-overview taxid?: string // For taxonomic-tree assemblyAccession?: string // For assemblies-list + totalAnnotations?: number // For download-tsv + buildDownloadParams?: () => any // For download-tsv } } From d0c07b2f4fa5efb30f1070d59f35505d1897a9ca Mon Sep 17 00:00:00 2001 From: Emilio Righi Date: Wed, 12 Aug 2026 11:26:48 +0200 Subject: [PATCH 5/5] fix actions --- .github/workflows/front-test.yml | 2 +- server/requirements-dev.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/front-test.yml b/.github/workflows/front-test.yml index 9708b0b..eb4e640 100644 --- a/.github/workflows/front-test.yml +++ b/.github/workflows/front-test.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: npm cache-dependency-path: front/package-lock.json diff --git a/server/requirements-dev.txt b/server/requirements-dev.txt index 415bdda..ad7656e 100644 --- a/server/requirements-dev.txt +++ b/server/requirements-dev.txt @@ -1,5 +1,6 @@ -r requirements.txt pytest +pytest-asyncio httpx mongomock coverage