mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat: implement version UI and short-sha CI versioning
This commit is contained in:
@@ -82,6 +82,7 @@ jobs:
|
|||||||
packages: read
|
packages: read
|
||||||
outputs:
|
outputs:
|
||||||
source_sha: ${{ steps.ctx.outputs.source_sha }}
|
source_sha: ${{ steps.ctx.outputs.source_sha }}
|
||||||
|
source_sha_short: ${{ steps.ctx.outputs.source_sha_short }}
|
||||||
update_main: ${{ steps.ctx.outputs.update_main }}
|
update_main: ${{ steps.ctx.outputs.update_main }}
|
||||||
skip_build: ${{ steps.ctx.outputs.skip_build }}
|
skip_build: ${{ steps.ctx.outputs.skip_build }}
|
||||||
steps:
|
steps:
|
||||||
@@ -108,14 +109,20 @@ jobs:
|
|||||||
SOURCE_SHA="$GH_SHA"
|
SOURCE_SHA="$GH_SHA"
|
||||||
echo "update_main=false" >> "$GITHUB_OUTPUT"
|
echo "update_main=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
SOURCE_SHA_SHORT="${SOURCE_SHA:0:12}"
|
||||||
echo "source_sha=${SOURCE_SHA}" >> "$GITHUB_OUTPUT"
|
echo "source_sha=${SOURCE_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "source_sha_short=${SOURCE_SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||||
IMAGE_LOWER=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')
|
IMAGE_LOWER=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')
|
||||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
HTTP_STATUS_SHORT=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||||
|
-H "Accept: application/vnd.oci.image.index.v1+json" \
|
||||||
|
"https://ghcr.io/v2/${IMAGE_LOWER}/manifests/sha-${SOURCE_SHA_SHORT}")
|
||||||
|
HTTP_STATUS_FULL=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||||
-H "Accept: application/vnd.oci.image.index.v1+json" \
|
-H "Accept: application/vnd.oci.image.index.v1+json" \
|
||||||
"https://ghcr.io/v2/${IMAGE_LOWER}/manifests/sha-${SOURCE_SHA}")
|
"https://ghcr.io/v2/${IMAGE_LOWER}/manifests/sha-${SOURCE_SHA}")
|
||||||
if [[ "$HTTP_STATUS" == "200" && "${FORCE_REBUILD}" != "true" ]]; then
|
if [[ ("$HTTP_STATUS_SHORT" == "200" || "$HTTP_STATUS_FULL" == "200") && "${FORCE_REBUILD}" != "true" ]]; then
|
||||||
echo "Image sha-${SOURCE_SHA} already exists in GHCR, skipping rebuild."
|
echo "Image sha-${SOURCE_SHA_SHORT} already exists in GHCR, skipping rebuild."
|
||||||
echo "skip_build=true" >> "$GITHUB_OUTPUT"
|
echo "skip_build=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "skip_build=false" >> "$GITHUB_OUTPUT"
|
echo "skip_build=false" >> "$GITHUB_OUTPUT"
|
||||||
@@ -159,6 +166,7 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
build-args: APP_VERSION=sha-${{ needs.prepare.outputs.source_sha_short }}
|
||||||
cache-from: type=gha,scope=container-image-yml-amd64
|
cache-from: type=gha,scope=container-image-yml-amd64
|
||||||
cache-to: type=gha,mode=max,scope=container-image-yml-amd64
|
cache-to: type=gha,mode=max,scope=container-image-yml-amd64
|
||||||
|
|
||||||
@@ -212,6 +220,7 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
|
build-args: APP_VERSION=sha-${{ needs.prepare.outputs.source_sha_short }}
|
||||||
cache-from: type=gha,scope=container-image-yml-arm64
|
cache-from: type=gha,scope=container-image-yml-arm64
|
||||||
cache-to: type=gha,mode=max,scope=container-image-yml-arm64
|
cache-to: type=gha,mode=max,scope=container-image-yml-arm64
|
||||||
|
|
||||||
@@ -255,13 +264,13 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: ${{ env.REGISTRY }}
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
IMAGE: ${{ env.IMAGE_NAME }}
|
IMAGE: ${{ env.IMAGE_NAME }}
|
||||||
SHA: ${{ needs.prepare.outputs.source_sha }}
|
SHA_SHORT: ${{ needs.prepare.outputs.source_sha_short }}
|
||||||
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
|
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
|
||||||
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
|
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
|
||||||
run: |
|
run: |
|
||||||
FULL_IMAGE="${REGISTRY}/${IMAGE}"
|
FULL_IMAGE="${REGISTRY}/${IMAGE}"
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--tag "${FULL_IMAGE}:sha-${SHA}" \
|
--tag "${FULL_IMAGE}:sha-${SHA_SHORT}" \
|
||||||
"${FULL_IMAGE}@${AMD64_DIGEST}" \
|
"${FULL_IMAGE}@${AMD64_DIGEST}" \
|
||||||
"${FULL_IMAGE}@${ARM64_DIGEST}"
|
"${FULL_IMAGE}@${ARM64_DIGEST}"
|
||||||
|
|
||||||
@@ -271,7 +280,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: ${{ env.REGISTRY }}
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
IMAGE: ${{ env.IMAGE_NAME }}
|
IMAGE: ${{ env.IMAGE_NAME }}
|
||||||
SHA: ${{ needs.prepare.outputs.source_sha }}
|
|
||||||
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
|
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
|
||||||
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
|
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
|
||||||
run: |
|
run: |
|
||||||
@@ -286,5 +294,5 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: ${{ env.REGISTRY }}
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
IMAGE: ${{ env.IMAGE_NAME }}
|
IMAGE: ${{ env.IMAGE_NAME }}
|
||||||
SHA: ${{ needs.prepare.outputs.source_sha }}
|
SHA_SHORT: ${{ needs.prepare.outputs.source_sha_short }}
|
||||||
run: docker buildx imagetools inspect "${REGISTRY}/${IMAGE}:sha-${SHA}"
|
run: docker buildx imagetools inspect "${REGISTRY}/${IMAGE}:sha-${SHA_SHORT}"
|
||||||
|
|||||||
@@ -142,12 +142,13 @@ jobs:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SOURCE_TAG="ghcr.io/${IMAGE_NAME}:sha-${COMMIT_SHA}"
|
SHORT_SHA="${COMMIT_SHA:0:12}"
|
||||||
|
SOURCE_TAG="ghcr.io/${IMAGE_NAME}:sha-${SHORT_SHA}"
|
||||||
if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then
|
if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then
|
||||||
echo "::error::Multi-arch image sha-${COMMIT_SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing."
|
echo "::error::Multi-arch image sha-${SHORT_SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Image sha-${COMMIT_SHA} verified in GHCR."
|
echo "Image sha-${SHORT_SHA} verified in GHCR."
|
||||||
|
|
||||||
- name: Set up Node.js and pnpm
|
- name: Set up Node.js and pnpm
|
||||||
uses: ./.github/actions/setup-node-pnpm
|
uses: ./.github/actions/setup-node-pnpm
|
||||||
@@ -192,7 +193,7 @@ jobs:
|
|||||||
needs: [release]
|
needs: [release]
|
||||||
if: needs.release.outputs.published == 'true'
|
if: needs.release.outputs.published == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
@@ -200,6 +201,14 @@ jobs:
|
|||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout release tag
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.release.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
|
|
||||||
@@ -210,46 +219,21 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Verify SHA image and resolve digest
|
- name: Build and push versioned image
|
||||||
id: source
|
id: build
|
||||||
shell: bash
|
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||||
env:
|
with:
|
||||||
REGISTRY: ${{ env.REGISTRY }}
|
context: .
|
||||||
IMAGE: ${{ env.IMAGE_NAME }}
|
file: ./Dockerfile
|
||||||
SHA: ${{ github.sha }}
|
push: true
|
||||||
run: |
|
platforms: linux/amd64,linux/arm64
|
||||||
FULL_IMAGE="${REGISTRY}/${IMAGE}"
|
tags: |
|
||||||
SOURCE_TAG="${FULL_IMAGE}:sha-${SHA}"
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release.outputs.version }}
|
||||||
if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
echo "::error::Multi-arch image sha-${SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing."
|
build-args: APP_VERSION=v${{ needs.release.outputs.version }}
|
||||||
exit 1
|
cache-from: |
|
||||||
fi
|
type=gha,scope=container-image-yml-amd64
|
||||||
DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "${SOURCE_TAG}" | jq -r '.digest')
|
type=gha,scope=container-image-yml-arm64
|
||||||
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "image=${FULL_IMAGE}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Promote to version and latest
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.release.outputs.version }}
|
|
||||||
DIGEST: ${{ steps.source.outputs.digest }}
|
|
||||||
IMAGE: ${{ steps.source.outputs.image }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create \
|
|
||||||
--tag "${IMAGE}:${VERSION}" \
|
|
||||||
"${IMAGE}@${DIGEST}"
|
|
||||||
docker buildx imagetools create \
|
|
||||||
--tag "${IMAGE}:latest" \
|
|
||||||
"${IMAGE}@${DIGEST}"
|
|
||||||
|
|
||||||
- name: Inspect promoted manifests
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.release.outputs.version }}
|
|
||||||
IMAGE: ${{ steps.source.outputs.image }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
|
||||||
docker buildx imagetools inspect "${IMAGE}:latest"
|
|
||||||
|
|
||||||
- name: Enrich release notes
|
- name: Enrich release notes
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -259,8 +243,8 @@ jobs:
|
|||||||
TAG: ${{ needs.release.outputs.tag }}
|
TAG: ${{ needs.release.outputs.tag }}
|
||||||
VERSION: ${{ needs.release.outputs.version }}
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
IMAGE: ${{ steps.source.outputs.image }}
|
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
DIGEST: ${{ steps.source.outputs.digest }}
|
DIGEST: ${{ steps.build.outputs.digest }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BODY_FILE="$(mktemp)"
|
BODY_FILE="$(mktemp)"
|
||||||
@@ -300,7 +284,8 @@ jobs:
|
|||||||
VERSION: ${{ needs.release.outputs.version }}
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
echo "## Docker Image Promoted" >> "$GITHUB_STEP_SUMMARY"
|
echo "## Docker Image Published" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- **Source:** \`sha-${SHA}\`" >> "$GITHUB_STEP_SUMMARY"
|
echo "- **Version:** \`${VERSION}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- **Tags:** \`${VERSION}\`, \`latest\` (linux/amd64, linux/arm64)" >> "$GITHUB_STEP_SUMMARY"
|
echo "- **Tags:** \`${VERSION}\`, \`latest\` (linux/amd64, linux/arm64)" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- **APP_VERSION baked:** \`v${VERSION}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ RUN mkdir -p /deploy/migrations && cp -r /app/server/src/db/migrations/. /deploy
|
|||||||
FROM ${NODE_IMAGE} AS runtime
|
FROM ${NODE_IMAGE} AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
RUN apk upgrade --no-cache && \
|
RUN apk upgrade --no-cache && \
|
||||||
apk add --no-cache poppler-utils su-exec ffmpeg && \
|
apk add --no-cache poppler-utils su-exec ffmpeg && \
|
||||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ import CreateSmartScopeDialog from '@/features/smart-scope/components/CreateSmar
|
|||||||
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
|
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
|
||||||
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
|
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
|
||||||
import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect'
|
import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect'
|
||||||
import { useInstallPrompt } from '@/features/pwa/composables/useInstallPrompt'
|
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
|
||||||
|
import { buildSidebarVersionUi } from '@/components/sidebar/versionUi'
|
||||||
|
|
||||||
function resolveIcon(name: string | null | undefined, fallback: Component): Component {
|
function resolveIcon(name: string | null | undefined, fallback: Component): Component {
|
||||||
if (name && name in Icons) return (Icons as Record<string, unknown>)[name] as Component
|
if (name && name in Icons) return (Icons as Record<string, unknown>)[name] as Component
|
||||||
@@ -56,9 +57,12 @@ const { hasPermission } = usePermissions()
|
|||||||
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
|
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
|
||||||
const { handleLibraryCreated } = useLibraryCreationRedirect()
|
const { handleLibraryCreated } = useLibraryCreationRedirect()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const { isInstallable, installApp } = useInstallPrompt()
|
const { version, updateAvailable, latestVersion, loadAppInfo } = useAppInfo()
|
||||||
|
|
||||||
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
||||||
|
const versionUi = computed(() => {
|
||||||
|
return buildSidebarVersionUi(version.value, updateAvailable.value, latestVersion.value)
|
||||||
|
})
|
||||||
|
|
||||||
const refreshedFor = new Set<number>()
|
const refreshedFor = new Set<number>()
|
||||||
watch(progressMap, (map) => {
|
watch(progressMap, (map) => {
|
||||||
@@ -147,6 +151,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
fetchSmartScopes()
|
fetchSmartScopes()
|
||||||
fetchCollections()
|
fetchCollections()
|
||||||
|
loadAppInfo()
|
||||||
})
|
})
|
||||||
|
|
||||||
const stopLibraryUploadListener = onLibraryUploadCompleted((event) => {
|
const stopLibraryUploadListener = onLibraryUploadCompleted((event) => {
|
||||||
@@ -433,10 +438,33 @@ onUnmounted(() => stopLibraryUploadListener())
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter v-if="isInstallable" class="border-t border-sidebar-border/60 pb-2">
|
<SidebarFooter v-if="versionUi.currentLabel" class="border-t border-sidebar-border/60">
|
||||||
<SidebarMenu>
|
<div class="px-2 py-2 group-data-[collapsible=icon]:hidden">
|
||||||
<SidebarNavItem :is-active="false" tooltip="Install App" :icon="Icons.Download" label="Install App" @click="installApp" />
|
<div class="flex flex-wrap items-center justify-center gap-2 text-center text-[12px] font-medium leading-none text-sidebar-foreground/80">
|
||||||
</SidebarMenu>
|
<a
|
||||||
|
v-if="versionUi.currentHref"
|
||||||
|
:href="versionUi.currentHref"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="transition-colors hover:text-sidebar-foreground"
|
||||||
|
>
|
||||||
|
{{ versionUi.currentLabel }}
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ versionUi.currentLabel }}</span>
|
||||||
|
|
||||||
|
<span v-if="versionUi.showLatest" class="text-sidebar-foreground/45">•</span>
|
||||||
|
|
||||||
|
<a
|
||||||
|
v-if="versionUi.showLatest"
|
||||||
|
:href="versionUi.latestHref"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="font-semibold text-primary transition-colors hover:text-primary/85"
|
||||||
|
>
|
||||||
|
Latest {{ versionUi.latestLabel }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
|
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { buildSidebarVersionUi } from '../versionUi'
|
||||||
|
|
||||||
|
describe('buildSidebarVersionUi', () => {
|
||||||
|
it('builds a single current-tag link when no update is available', () => {
|
||||||
|
const ui = buildSidebarVersionUi('v1.2.3', false, 'v1.3.0')
|
||||||
|
|
||||||
|
expect(ui).toEqual({
|
||||||
|
currentLabel: 'v1.2.3',
|
||||||
|
currentHref: 'https://github.com/bookorbit/bookorbit/releases/tag/v1.2.3',
|
||||||
|
showLatest: false,
|
||||||
|
latestLabel: 'v1.3.0',
|
||||||
|
latestHref: 'https://github.com/bookorbit/bookorbit/releases/tag/v1.3.0',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows both current and latest links when an update is available', () => {
|
||||||
|
const ui = buildSidebarVersionUi('v1.2.3', true, 'v1.4.0')
|
||||||
|
|
||||||
|
expect(ui.currentLabel).toBe('Current v1.2.3')
|
||||||
|
expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v1.2.3')
|
||||||
|
expect(ui.showLatest).toBe(true)
|
||||||
|
expect(ui.latestLabel).toBe('v1.4.0')
|
||||||
|
expect(ui.latestHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v1.4.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show latest when latestVersion is null', () => {
|
||||||
|
const ui = buildSidebarVersionUi('v1.2.3', true, null)
|
||||||
|
|
||||||
|
expect(ui.showLatest).toBe(false)
|
||||||
|
expect(ui.currentLabel).toBe('v1.2.3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show latest when latestVersion is blank after trimming', () => {
|
||||||
|
const ui = buildSidebarVersionUi('v1.2.3', true, ' ')
|
||||||
|
|
||||||
|
expect(ui.showLatest).toBe(false)
|
||||||
|
expect(ui.currentLabel).toBe('v1.2.3')
|
||||||
|
expect(ui.latestLabel).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows local build labels as-is', () => {
|
||||||
|
const ui = buildSidebarVersionUi('Local build', null, null)
|
||||||
|
|
||||||
|
expect(ui.currentLabel).toBe('Local build')
|
||||||
|
expect(ui.currentHref).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims version strings before building labels and URLs', () => {
|
||||||
|
const ui = buildSidebarVersionUi(' v2.0.1 ', true, ' v2.1.0 ')
|
||||||
|
|
||||||
|
expect(ui.currentLabel).toBe('Current v2.0.1')
|
||||||
|
expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v2.0.1')
|
||||||
|
expect(ui.latestLabel).toBe('v2.1.0')
|
||||||
|
expect(ui.latestHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v2.1.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back latest link to /releases/latest for non-tag latest versions', () => {
|
||||||
|
const ui = buildSidebarVersionUi('v1.2.3', true, 'main-abc123')
|
||||||
|
|
||||||
|
expect(ui.showLatest).toBe(true)
|
||||||
|
expect(ui.latestLabel).toBe('main-abc123')
|
||||||
|
expect(ui.latestHref).toBe('https://github.com/bookorbit/bookorbit/releases/latest')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links sha versions to commits and does not enable latest mode', () => {
|
||||||
|
const ui = buildSidebarVersionUi('sha-abc1234', true, 'v1.2.4')
|
||||||
|
|
||||||
|
expect(ui.currentLabel).toBe('sha-abc1234')
|
||||||
|
expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/commit/abc1234')
|
||||||
|
expect(ui.showLatest).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not enable latest mode for local builds', () => {
|
||||||
|
const ui = buildSidebarVersionUi('Local build', true, 'v1.2.4')
|
||||||
|
|
||||||
|
expect(ui.currentLabel).toBe('Local build')
|
||||||
|
expect(ui.showLatest).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shortens long sha labels to 12 chars for readability', () => {
|
||||||
|
const ui = buildSidebarVersionUi('sha-1234567890abcdef1234567890abcdef12345678', null, null)
|
||||||
|
|
||||||
|
expect(ui.currentLabel).toBe('sha-1234567890ab')
|
||||||
|
expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/commit/1234567890abcdef1234567890abcdef12345678')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links short sha versions to the matching commit page', () => {
|
||||||
|
const ui = buildSidebarVersionUi('sha-1234567890ab', null, null)
|
||||||
|
|
||||||
|
expect(ui.currentLabel).toBe('sha-1234567890ab')
|
||||||
|
expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/commit/1234567890ab')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not prefix current label when updateAvailable is null', () => {
|
||||||
|
const ui = buildSidebarVersionUi('v1.2.3', null, 'v1.3.0')
|
||||||
|
|
||||||
|
expect(ui.currentLabel).toBe('v1.2.3')
|
||||||
|
expect(ui.showLatest).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses latest presentation when current version is blank', () => {
|
||||||
|
const ui = buildSidebarVersionUi(' ', true, 'v1.3.0')
|
||||||
|
|
||||||
|
expect(ui.currentLabel).toBe('')
|
||||||
|
expect(ui.showLatest).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type SidebarVersionUi = {
|
||||||
|
currentLabel: string
|
||||||
|
currentHref: string | null
|
||||||
|
showLatest: boolean
|
||||||
|
latestLabel: string
|
||||||
|
latestHref: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const GITHUB_RELEASES_BASE = 'https://github.com/bookorbit/bookorbit/releases'
|
||||||
|
const GITHUB_COMMIT_BASE = 'https://github.com/bookorbit/bookorbit/commit'
|
||||||
|
|
||||||
|
function isVersionTag(value: string): boolean {
|
||||||
|
return /^v\d+\.\d+\.\d+$/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSha(value: string): string | null {
|
||||||
|
const match = value.match(/^(?:sha-)?([0-9a-f]{7,40})$/i)
|
||||||
|
return match ? (match[1] ?? null) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVersionLabel(value: string): string {
|
||||||
|
const normalized = value.trim()
|
||||||
|
if (!normalized) return ''
|
||||||
|
const sha = extractSha(normalized)
|
||||||
|
if (sha) return `sha-${sha.slice(0, 12)}`
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSidebarVersionUi(version: string, updateAvailable: boolean | null, latestVersion: string | null): SidebarVersionUi {
|
||||||
|
const currentRaw = version.trim()
|
||||||
|
const latestRaw = (latestVersion ?? '').trim()
|
||||||
|
const currentIsTag = isVersionTag(currentRaw)
|
||||||
|
const currentSha = extractSha(currentRaw)
|
||||||
|
const currentLabelBase = normalizeVersionLabel(currentRaw)
|
||||||
|
const showLatest = Boolean(updateAvailable && latestRaw && currentLabelBase && currentIsTag)
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentLabel: showLatest ? `Current ${currentLabelBase}` : currentLabelBase,
|
||||||
|
currentHref: currentIsTag ? `${GITHUB_RELEASES_BASE}/tag/${currentRaw}` : currentSha ? `${GITHUB_COMMIT_BASE}/${currentSha}` : null,
|
||||||
|
showLatest,
|
||||||
|
latestLabel: normalizeVersionLabel(latestRaw),
|
||||||
|
latestHref: isVersionTag(latestRaw) ? `${GITHUB_RELEASES_BASE}/tag/${latestRaw}` : `${GITHUB_RELEASES_BASE}/latest`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2 } from 'lucide-vue-next'
|
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2, Bell } from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||||
import MigrationModal from '@/features/migration/components/MigrationModal.vue'
|
import MigrationModal from '@/features/migration/components/MigrationModal.vue'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@@ -16,6 +17,9 @@ const embeddingError = ref<string | null>(null)
|
|||||||
const migrationState = ref<MigrationWorkflowState | null>(null)
|
const migrationState = ref<MigrationWorkflowState | null>(null)
|
||||||
const migrationLoading = ref(true)
|
const migrationLoading = ref(true)
|
||||||
|
|
||||||
|
const updateCheckEnabled = ref(true)
|
||||||
|
const updateCheckLoading = ref(false)
|
||||||
|
|
||||||
const migrationSource = computed(() => migrationState.value?.active?.source ?? null)
|
const migrationSource = computed(() => migrationState.value?.active?.source ?? null)
|
||||||
const migrationRun = computed(() => migrationState.value?.active?.run ?? null)
|
const migrationRun = computed(() => migrationState.value?.active?.run ?? null)
|
||||||
|
|
||||||
@@ -34,6 +38,17 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
migrationLoading.value = false
|
migrationLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api('/api/v1/app-settings')
|
||||||
|
if (res.ok) {
|
||||||
|
const settings: { key: string; value: string }[] = await res.json()
|
||||||
|
const row = settings.find((s) => s.key === 'update_check_enabled')
|
||||||
|
if (row) updateCheckEnabled.value = row.value === 'true'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onMigrationModalClose() {
|
async function onMigrationModalClose() {
|
||||||
@@ -49,6 +64,27 @@ function goToMigration() {
|
|||||||
showMigrationModal.value = true
|
showMigrationModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleUpdateCheck(newVal: boolean) {
|
||||||
|
updateCheckLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api('/api/v1/app-settings/update_check_enabled', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value: String(newVal) }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
updateCheckEnabled.value = newVal
|
||||||
|
toast.success(`Update checks ${newVal ? 'enabled' : 'disabled'}`)
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to update setting')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update setting')
|
||||||
|
} finally {
|
||||||
|
updateCheckLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function rebuildEmbeddings() {
|
async function rebuildEmbeddings() {
|
||||||
running.value = true
|
running.value = true
|
||||||
queued.value = null
|
queued.value = null
|
||||||
@@ -194,5 +230,26 @@ function formatDate(iso: string | null | undefined): string {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Update checks -->
|
||||||
|
<div>
|
||||||
|
<p class="settings-group-label">Updates</p>
|
||||||
|
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Bell :size="16" class="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="settings-label">Check for updates</p>
|
||||||
|
<p class="settings-hint">
|
||||||
|
Automatically check GitHub for a new release on startup and show an indicator in the sidebar when one is available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch :model-value="updateCheckEnabled" :disabled="updateCheckLoading" @update:model-value="toggleUpdateCheck" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
|
||||||
|
vi.mock('@/lib/api', () => ({
|
||||||
|
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { useAppInfo } from '../useAppInfo'
|
||||||
|
|
||||||
|
const mockApi = api as ReturnType<typeof vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>>
|
||||||
|
|
||||||
|
function makeOkResponse(data: object): Response {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(data),
|
||||||
|
} as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeErrorResponse(status: number): Response {
|
||||||
|
return { ok: false, status, json: vi.fn<() => Promise<unknown>>() } as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useAppInfo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
|
||||||
|
version.value = ''
|
||||||
|
updateAvailable.value = null
|
||||||
|
latestVersion.value = null
|
||||||
|
isLoading.value = false
|
||||||
|
error.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts in idle state before loadAppInfo is called', () => {
|
||||||
|
const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
|
||||||
|
expect(version.value).toBe('')
|
||||||
|
expect(updateAvailable.value).toBeNull()
|
||||||
|
expect(latestVersion.value).toBeNull()
|
||||||
|
expect(isLoading.value).toBe(false)
|
||||||
|
expect(error.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves with data from API response', async () => {
|
||||||
|
mockApi.mockResolvedValue(
|
||||||
|
makeOkResponse({
|
||||||
|
version: 'v1.2.3',
|
||||||
|
updateAvailable: false,
|
||||||
|
latestVersion: 'v1.2.3',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { version, updateAvailable, latestVersion, isLoading, loadAppInfo } = useAppInfo()
|
||||||
|
const promise = loadAppInfo()
|
||||||
|
expect(isLoading.value).toBe(true)
|
||||||
|
await promise
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(version.value).toBe('v1.2.3')
|
||||||
|
expect(updateAvailable.value).toBe(false)
|
||||||
|
expect(latestVersion.value).toBe('v1.2.3')
|
||||||
|
expect(isLoading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets updateAvailable: true when a newer version is available', async () => {
|
||||||
|
mockApi.mockResolvedValue(
|
||||||
|
makeOkResponse({
|
||||||
|
version: 'v1.2.3',
|
||||||
|
updateAvailable: true,
|
||||||
|
latestVersion: 'v1.3.0',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { updateAvailable, latestVersion, loadAppInfo } = useAppInfo()
|
||||||
|
await loadAppInfo()
|
||||||
|
|
||||||
|
expect(updateAvailable.value).toBe(true)
|
||||||
|
expect(latestVersion.value).toBe('v1.3.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets updateAvailable: null for local builds', async () => {
|
||||||
|
mockApi.mockResolvedValue(
|
||||||
|
makeOkResponse({
|
||||||
|
version: 'Local build',
|
||||||
|
updateAvailable: null,
|
||||||
|
latestVersion: null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { updateAvailable, version, loadAppInfo } = useAppInfo()
|
||||||
|
await loadAppInfo()
|
||||||
|
|
||||||
|
expect(version.value).toBe('Local build')
|
||||||
|
expect(updateAvailable.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles API error gracefully - sets error ref, does not throw', async () => {
|
||||||
|
mockApi.mockRejectedValue(new Error('network failure'))
|
||||||
|
|
||||||
|
const { error, version, loadAppInfo } = useAppInfo()
|
||||||
|
await expect(loadAppInfo()).resolves.not.toThrow()
|
||||||
|
|
||||||
|
expect(error.value).toBe('network failure')
|
||||||
|
expect(version.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles non-ok HTTP response gracefully', async () => {
|
||||||
|
mockApi.mockResolvedValue(makeErrorResponse(500))
|
||||||
|
|
||||||
|
const { error, loadAppInfo } = useAppInfo()
|
||||||
|
await loadAppInfo()
|
||||||
|
|
||||||
|
expect(error.value).toMatch(/HTTP 500/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets isLoading to false after error', async () => {
|
||||||
|
mockApi.mockRejectedValue(new Error('fail'))
|
||||||
|
|
||||||
|
const { isLoading, loadAppInfo } = useAppInfo()
|
||||||
|
await loadAppInfo()
|
||||||
|
|
||||||
|
expect(isLoading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not start a concurrent fetch while one is in progress', async () => {
|
||||||
|
let resolveFirst!: (value: Response) => void
|
||||||
|
mockApi.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
resolveFirst = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { loadAppInfo } = useAppInfo()
|
||||||
|
const first = loadAppInfo()
|
||||||
|
const second = loadAppInfo()
|
||||||
|
|
||||||
|
resolveFirst(
|
||||||
|
makeOkResponse({
|
||||||
|
version: 'v1.0.0',
|
||||||
|
updateAvailable: false,
|
||||||
|
latestVersion: 'v1.0.0',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await Promise.all([first, second])
|
||||||
|
|
||||||
|
expect(mockApi).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import type { AppInfoResponse } from '@bookorbit/types'
|
||||||
|
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
|
const version = ref('')
|
||||||
|
const updateAvailable = ref<boolean | null>(null)
|
||||||
|
const latestVersion = ref<string | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
export function useAppInfo() {
|
||||||
|
async function loadAppInfo() {
|
||||||
|
if (isLoading.value) return
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await api('/api/v1/app-info')
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data: AppInfoResponse = await res.json()
|
||||||
|
version.value = data.version
|
||||||
|
updateAvailable.value = data.updateAvailable
|
||||||
|
latestVersion.value = data.latestVersion
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load app info'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { version, updateAvailable, latestVersion, isLoading, error, loadAppInfo }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface AppInfoResponse {
|
||||||
|
version: string;
|
||||||
|
updateAvailable: boolean | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ export * from "./statistics";
|
|||||||
export * from "./user-statistics";
|
export * from "./user-statistics";
|
||||||
export * from "./migration";
|
export * from "./migration";
|
||||||
export * from "./notification";
|
export * from "./notification";
|
||||||
|
export * from "./app-info";
|
||||||
export * from "./series";
|
export * from "./series";
|
||||||
export * from "./table-layout";
|
export * from "./table-layout";
|
||||||
export * from "./series-collapse";
|
export * from "./series-collapse";
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { SeriesModule } from './modules/series/series.module';
|
|||||||
import { EntityManagerModule } from './modules/entity-manager/entity-manager.module';
|
import { EntityManagerModule } from './modules/entity-manager/entity-manager.module';
|
||||||
import { FontModule } from './modules/font/font.module';
|
import { FontModule } from './modules/font/font.module';
|
||||||
import { KoreaderModule } from './modules/koreader/koreader.module';
|
import { KoreaderModule } from './modules/koreader/koreader.module';
|
||||||
|
import { AppInfoModule } from './modules/app-info/app-info.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -122,6 +123,7 @@ import { KoreaderModule } from './modules/koreader/koreader.module';
|
|||||||
EntityManagerModule,
|
EntityManagerModule,
|
||||||
FontModule,
|
FontModule,
|
||||||
KoreaderModule,
|
KoreaderModule,
|
||||||
|
AppInfoModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const APP_SETTING_KEYS = {
|
|||||||
METADATA_SCORE_WEIGHTS: 'metadata_score_weights',
|
METADATA_SCORE_WEIGHTS: 'metadata_score_weights',
|
||||||
AUDIT_RETENTION_DAYS: 'audit_retention_days',
|
AUDIT_RETENTION_DAYS: 'audit_retention_days',
|
||||||
INITIAL_SETUP_COMPLETED_AT: 'initial_setup_completed_at',
|
INITIAL_SETUP_COMPLETED_AT: 'initial_setup_completed_at',
|
||||||
|
UPDATE_CHECK_ENABLED: 'update_check_enabled',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
|
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
import { appConfig, authConfig, dbConfig, emailConfig, fileWriteConfig, migrationConfig, oidcRuntimeConfig, storageConfig } from './config';
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = process.env;
|
||||||
|
|
||||||
|
function resetEnv(): void {
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
delete process.env.APP_URL;
|
||||||
|
delete process.env.APP_VERSION;
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
delete process.env.JWT_SECRET;
|
||||||
|
delete process.env.JWT_EXPIRES_IN;
|
||||||
|
delete process.env.JWT_REFRESH_EXPIRES_IN;
|
||||||
|
delete process.env.SETUP_BOOTSTRAP_TOKEN;
|
||||||
|
delete process.env.APP_DATA_PATH;
|
||||||
|
delete process.env.FILE_WRITE_DEBOUNCE_MS;
|
||||||
|
delete process.env.FILE_WRITE_MAX_CONCURRENT_WRITES;
|
||||||
|
delete process.env.EMAIL_ENCRYPTION_KEY;
|
||||||
|
delete process.env.MIGRATION_ENCRYPTION_KEY;
|
||||||
|
delete process.env.OIDC_STATE_TTL_SECS;
|
||||||
|
delete process.env.OIDC_DISCOVERY_CACHE_TTL_SECS;
|
||||||
|
delete process.env.OIDC_JWKS_CACHE_TTL_SECS;
|
||||||
|
delete process.env.OIDC_CLOCK_TOLERANCE_SECS;
|
||||||
|
delete process.env.OIDC_TOKEN_EXCHANGE_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('config', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetEnv();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env = ORIGINAL_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses app defaults, including local-build fallback version', () => {
|
||||||
|
expect(appConfig()).toEqual({
|
||||||
|
nodeEnv: 'development',
|
||||||
|
appUrl: 'http://localhost:5173',
|
||||||
|
version: 'Local build',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads app values from environment when provided', () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
process.env.APP_URL = 'https://bookorbit.local';
|
||||||
|
process.env.APP_VERSION = 'v2.3.4';
|
||||||
|
|
||||||
|
expect(appConfig()).toEqual({
|
||||||
|
nodeEnv: 'production',
|
||||||
|
appUrl: 'https://bookorbit.local',
|
||||||
|
version: 'v2.3.4',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses defaults for database, auth, email, and migration config', () => {
|
||||||
|
expect(dbConfig().url).toBe('postgres://bookorbit:bookorbit@localhost:5432/bookorbit');
|
||||||
|
expect(authConfig()).toEqual({
|
||||||
|
jwtSecret: 'change-me-in-production',
|
||||||
|
jwtExpiresIn: '15m',
|
||||||
|
jwtRefreshExpiresIn: '7d',
|
||||||
|
setupBootstrapToken: '',
|
||||||
|
});
|
||||||
|
expect(emailConfig().encryptionKey).toBe('');
|
||||||
|
expect(migrationConfig().encryptionKey).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves storage path from APP_DATA_PATH', () => {
|
||||||
|
process.env.APP_DATA_PATH = './tmp/bookorbit-data';
|
||||||
|
expect(storageConfig().appDataPath).toBe(resolve('./tmp/bookorbit-data'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back storage path to /data when APP_DATA_PATH is not set', () => {
|
||||||
|
expect(storageConfig().appDataPath).toBe(resolve('/data'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses positive integers for file-write config and floors decimal values', () => {
|
||||||
|
process.env.FILE_WRITE_DEBOUNCE_MS = '1234.99';
|
||||||
|
process.env.FILE_WRITE_MAX_CONCURRENT_WRITES = '4.2';
|
||||||
|
|
||||||
|
expect(fileWriteConfig()).toEqual({
|
||||||
|
debounceMs: 1234,
|
||||||
|
maxConcurrentWrites: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses file-write fallbacks for zero, negatives, NaN, and Infinity', () => {
|
||||||
|
process.env.FILE_WRITE_DEBOUNCE_MS = '0';
|
||||||
|
process.env.FILE_WRITE_MAX_CONCURRENT_WRITES = '-7';
|
||||||
|
expect(fileWriteConfig()).toEqual({
|
||||||
|
debounceMs: 3000,
|
||||||
|
maxConcurrentWrites: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.FILE_WRITE_DEBOUNCE_MS = 'nope';
|
||||||
|
process.env.FILE_WRITE_MAX_CONCURRENT_WRITES = 'Infinity';
|
||||||
|
expect(fileWriteConfig()).toEqual({
|
||||||
|
debounceMs: 3000,
|
||||||
|
maxConcurrentWrites: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes OIDC runtime values from seconds with millisecond conversion', () => {
|
||||||
|
process.env.OIDC_STATE_TTL_SECS = '11';
|
||||||
|
process.env.OIDC_DISCOVERY_CACHE_TTL_SECS = '22';
|
||||||
|
process.env.OIDC_JWKS_CACHE_TTL_SECS = '33';
|
||||||
|
process.env.OIDC_CLOCK_TOLERANCE_SECS = '44';
|
||||||
|
process.env.OIDC_TOKEN_EXCHANGE_TIMEOUT_MS = '5555';
|
||||||
|
|
||||||
|
expect(oidcRuntimeConfig()).toEqual({
|
||||||
|
stateTtlMs: 11000,
|
||||||
|
discoveryCacheTtlMs: 22000,
|
||||||
|
jwksCacheTtlMs: 33000,
|
||||||
|
clockToleranceSecs: 44,
|
||||||
|
tokenExchangeTimeoutMs: 5555,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses OIDC defaults when values are missing or invalid', () => {
|
||||||
|
process.env.OIDC_STATE_TTL_SECS = '';
|
||||||
|
process.env.OIDC_DISCOVERY_CACHE_TTL_SECS = '0';
|
||||||
|
process.env.OIDC_JWKS_CACHE_TTL_SECS = '-1';
|
||||||
|
process.env.OIDC_CLOCK_TOLERANCE_SECS = 'abc';
|
||||||
|
process.env.OIDC_TOKEN_EXCHANGE_TIMEOUT_MS = 'Infinity';
|
||||||
|
|
||||||
|
expect(oidcRuntimeConfig()).toEqual({
|
||||||
|
stateTtlMs: 300000,
|
||||||
|
discoveryCacheTtlMs: 3600000,
|
||||||
|
jwksCacheTtlMs: 21600000,
|
||||||
|
clockToleranceSecs: 30,
|
||||||
|
tokenExchangeTimeoutMs: 10000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { resolve } from 'path';
|
|||||||
export const appConfig = registerAs('app', () => ({
|
export const appConfig = registerAs('app', () => ({
|
||||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
|
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
|
||||||
|
version: process.env.APP_VERSION ?? 'Local build',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const dbConfig = registerAs('db', () => ({
|
export const dbConfig = registerAs('db', () => ({
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const APP_INFO_ROUTE = 'app-info';
|
||||||
|
export const GITHUB_RELEASES_API = 'https://api.github.com/repos/bookorbit/bookorbit/releases/latest';
|
||||||
|
export const GITHUB_RELEASE_URL = 'https://github.com/bookorbit/bookorbit/releases/tag';
|
||||||
|
export const UPDATE_CHECK_TIMEOUT_MS = 10_000;
|
||||||
|
export const SEMVER_RE = /^v\d+\.\d+\.\d+/;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { RequestMethod } from '@nestjs/common';
|
||||||
|
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||||
|
|
||||||
|
import type { AppInfoResponse } from '@bookorbit/types';
|
||||||
|
|
||||||
|
import { APP_INFO_ROUTE } from './app-info.constants';
|
||||||
|
import { AppInfoController } from './app-info.controller';
|
||||||
|
import { AppInfoService } from './app-info.service';
|
||||||
|
|
||||||
|
function makeService(): jest.Mocked<AppInfoService> {
|
||||||
|
return {
|
||||||
|
getAppInfo: vi.fn(),
|
||||||
|
onApplicationBootstrap: vi.fn(),
|
||||||
|
} as unknown as jest.Mocked<AppInfoService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppInfoController', () => {
|
||||||
|
it('keeps the expected route contract for the app-info endpoint', () => {
|
||||||
|
const classPath = Reflect.getMetadata(PATH_METADATA, AppInfoController);
|
||||||
|
const methodType = Reflect.getMetadata(METHOD_METADATA, AppInfoController.prototype.getAppInfo);
|
||||||
|
|
||||||
|
expect(classPath).toBe(APP_INFO_ROUTE);
|
||||||
|
expect(methodType).toBe(RequestMethod.GET);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAppInfo', () => {
|
||||||
|
it('delegates to the service and returns the result', () => {
|
||||||
|
const service = makeService();
|
||||||
|
const expected: AppInfoResponse = {
|
||||||
|
version: 'v1.2.3',
|
||||||
|
updateAvailable: true,
|
||||||
|
latestVersion: 'v1.3.0',
|
||||||
|
};
|
||||||
|
service.getAppInfo.mockReturnValue(expected);
|
||||||
|
const controller = new AppInfoController(service);
|
||||||
|
|
||||||
|
const result = controller.getAppInfo();
|
||||||
|
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
expect(service.getAppInfo).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns updateAvailable: null when service returns null', () => {
|
||||||
|
const service = makeService();
|
||||||
|
const expected: AppInfoResponse = { version: 'Local build', updateAvailable: null, latestVersion: null };
|
||||||
|
service.getAppInfo.mockReturnValue(expected);
|
||||||
|
const controller = new AppInfoController(service);
|
||||||
|
|
||||||
|
expect(controller.getAppInfo()).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { AppInfoResponse } from '@bookorbit/types';
|
||||||
|
|
||||||
|
import { APP_INFO_ROUTE } from './app-info.constants';
|
||||||
|
import { AppInfoService } from './app-info.service';
|
||||||
|
|
||||||
|
@Controller(APP_INFO_ROUTE)
|
||||||
|
export class AppInfoController {
|
||||||
|
constructor(private readonly appInfoService: AppInfoService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getAppInfo(): AppInfoResponse {
|
||||||
|
return this.appInfoService.getAppInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||||
|
import { AppInfoController } from './app-info.controller';
|
||||||
|
import { AppInfoService } from './app-info.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AppSettingsModule],
|
||||||
|
controllers: [AppInfoController],
|
||||||
|
providers: [AppInfoService],
|
||||||
|
})
|
||||||
|
export class AppInfoModule {}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||||
|
import { GITHUB_RELEASES_API } from './app-info.constants';
|
||||||
|
import { AppInfoService } from './app-info.service';
|
||||||
|
|
||||||
|
function makeConfig(version = 'v1.2.3'): jest.Mocked<ConfigService> {
|
||||||
|
return { get: vi.fn().mockReturnValue(version) } as unknown as jest.Mocked<ConfigService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAppSettings(enabled = true): jest.Mocked<AppSettingsService> {
|
||||||
|
return {
|
||||||
|
isUpdateCheckEnabled: vi.fn().mockResolvedValue(enabled),
|
||||||
|
} as unknown as jest.Mocked<AppSettingsService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGithubResponse(tagName: string, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: vi.fn().mockResolvedValue({ tag_name: tagName }),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppInfoService', () => {
|
||||||
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
|
warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAppInfo', () => {
|
||||||
|
it('returns version from config', () => {
|
||||||
|
const service = new AppInfoService(makeConfig('v2.0.0'), makeAppSettings());
|
||||||
|
expect(service.getAppInfo().version).toBe('v2.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to "Local build" when config returns undefined', () => {
|
||||||
|
const config = { get: vi.fn().mockReturnValue(undefined) } as unknown as ConfigService;
|
||||||
|
const service = new AppInfoService(config, makeAppSettings());
|
||||||
|
expect(service.getAppInfo().version).toBe('Local build');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for updateAvailable before bootstrap', () => {
|
||||||
|
const service = new AppInfoService(makeConfig(), makeAppSettings());
|
||||||
|
const info = service.getAppInfo();
|
||||||
|
expect(info.updateAvailable).toBeNull();
|
||||||
|
expect(info.latestVersion).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onApplicationBootstrap', () => {
|
||||||
|
it('fetches GitHub API and sets updateAvailable: true when newer version exists', async () => {
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
const info = service.getAppInfo();
|
||||||
|
expect(info.updateAvailable).toBe(true);
|
||||||
|
expect(info.latestVersion).toBe('v1.3.0');
|
||||||
|
expect(fetch).toHaveBeenCalledWith(GITHUB_RELEASES_API, expect.objectContaining({ headers: expect.any(Object) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets updateAvailable: false when already on latest', async () => {
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.2.3'));
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets updateAvailable: false when running newer than latest (downgrade scenario)', async () => {
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.2.2'));
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly compares minor version bumps', async () => {
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.9'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly compares major version bumps', async () => {
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v2.0.0'));
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.9.9'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fetch and leaves updateAvailable: null when version is "Local build"', async () => {
|
||||||
|
const fetchSpy = vi.spyOn(global, 'fetch');
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('Local build'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fetch when version is a sha-prefixed string', async () => {
|
||||||
|
const fetchSpy = vi.spyOn(global, 'fetch');
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('sha-abc1234'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fetch when update checks are disabled', async () => {
|
||||||
|
const fetchSpy = vi.spyOn(global, 'fetch');
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(false));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves updateAvailable: null on network error (does not throw)', async () => {
|
||||||
|
vi.spyOn(global, 'fetch').mockRejectedValue(new TypeError('fetch failed'));
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBeNull();
|
||||||
|
expect(warnSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves updateAvailable: null on AbortError timeout (does not throw)', async () => {
|
||||||
|
const abortError = new Error('The operation was aborted');
|
||||||
|
abortError.name = 'AbortError';
|
||||||
|
vi.spyOn(global, 'fetch').mockRejectedValue(abortError);
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await expect(service.onApplicationBootstrap()).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBeNull();
|
||||||
|
expect(warnSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves updateAvailable: null when GitHub API returns non-200', async () => {
|
||||||
|
const badResponse: Response = {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(badResponse);
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBeNull();
|
||||||
|
expect(warnSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves updateAvailable: null when GitHub API returns null body', async () => {
|
||||||
|
const response: Response = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: vi.fn().mockResolvedValue(null),
|
||||||
|
} as unknown as Response;
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(response);
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBeNull();
|
||||||
|
expect(warnSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves updateAvailable: null when tag_name is missing from response', async () => {
|
||||||
|
const response: Response = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: vi.fn().mockResolvedValue({ name: 'Release 1.3.0' }),
|
||||||
|
} as unknown as Response;
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(response);
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBeNull();
|
||||||
|
expect(warnSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves updateAvailable: null when tag_name is not a valid semver', async () => {
|
||||||
|
const response: Response = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: vi.fn().mockResolvedValue({ tag_name: 'not-a-version' }),
|
||||||
|
} as unknown as Response;
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(response);
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBeNull();
|
||||||
|
expect(warnSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves updateAvailable: null when response body is not an object', async () => {
|
||||||
|
const response: Response = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: vi.fn().mockResolvedValue('just a string'),
|
||||||
|
} as unknown as Response;
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(response);
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(service.getAppInfo().updateAvailable).toBeNull();
|
||||||
|
expect(warnSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs start and end on success', async () => {
|
||||||
|
vi.spyOn(global, 'fetch').mockResolvedValue(makeGithubResponse('v1.3.0'));
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(logSpy.mock.calls[0][0]).toContain('[start]');
|
||||||
|
expect(logSpy.mock.calls[1][0]).toContain('[end]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs warn on failure', async () => {
|
||||||
|
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('network error'));
|
||||||
|
|
||||||
|
const service = new AppInfoService(makeConfig('v1.2.3'), makeAppSettings(true));
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalledOnce();
|
||||||
|
expect(warnSpy.mock.calls[0][0]).toContain('[fail]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import type { AppInfoResponse } from '@bookorbit/types';
|
||||||
|
|
||||||
|
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||||
|
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||||
|
import { GITHUB_RELEASES_API, SEMVER_RE, UPDATE_CHECK_TIMEOUT_MS } from './app-info.constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppInfoService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(AppInfoService.name);
|
||||||
|
private updateAvailable: boolean | null = null;
|
||||||
|
private latestVersion: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly appSettingsService: AppSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
|
const version = this.config.get<string>('app.version') ?? 'Local build';
|
||||||
|
const enabled = await this.appSettingsService.isUpdateCheckEnabled();
|
||||||
|
|
||||||
|
if (!enabled || !SEMVER_RE.test(version)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.checkForUpdate(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAppInfo(): AppInfoResponse {
|
||||||
|
return {
|
||||||
|
version: this.config.get<string>('app.version') ?? 'Local build',
|
||||||
|
updateAvailable: this.updateAvailable,
|
||||||
|
latestVersion: this.latestVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkForUpdate(currentVersion: string): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
this.logger.log(`[app-info.update_check] [start] version=${currentVersion} - checking for updates`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(GITHUB_RELEASES_API, {
|
||||||
|
headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
this.logger.warn(
|
||||||
|
`[app-info.update_check] [fail] version=${currentVersion} durationMs=${durationMs} errorClass=HttpError error="GitHub API returned ${response.status}" - update check failed`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: unknown = await response.json();
|
||||||
|
const tagName = this.extractTagName(data);
|
||||||
|
|
||||||
|
if (!tagName) {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
this.logger.warn(
|
||||||
|
`[app-info.update_check] [fail] version=${currentVersion} durationMs=${durationMs} errorClass=ParseError error="unexpected response shape" - update check failed`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.latestVersion = tagName;
|
||||||
|
this.updateAvailable = this.isNewer(tagName, currentVersion);
|
||||||
|
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
this.logger.log(
|
||||||
|
`[app-info.update_check] [end] version=${currentVersion} latestVersion=${tagName} updateAvailable=${this.updateAvailable} durationMs=${durationMs} - update check completed`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError';
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(
|
||||||
|
`[app-info.update_check] [fail] version=${currentVersion} durationMs=${durationMs} errorClass=${errorClass} error="${sanitizeLogValue(message)}" - update check failed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTagName(data: unknown): string | null {
|
||||||
|
if (typeof data !== 'object' || data === null) return null;
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
const tagName = record['tag_name'];
|
||||||
|
if (typeof tagName !== 'string' || !SEMVER_RE.test(tagName)) return null;
|
||||||
|
return tagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNewer(latestTag: string, currentVersion: string): boolean {
|
||||||
|
const latest = this.parseSemver(latestTag.replace(/^v/, ''));
|
||||||
|
const current = this.parseSemver(currentVersion.replace(/^v/, ''));
|
||||||
|
if (!latest || !current) return false;
|
||||||
|
if (latest[0] !== current[0]) return latest[0] > current[0];
|
||||||
|
if (latest[1] !== current[1]) return latest[1] > current[1];
|
||||||
|
return latest[2] > current[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSemver(version: string): [number, number, number] | null {
|
||||||
|
const parts = version.split('.').map(Number);
|
||||||
|
if (parts.length < 3 || parts.some((p) => isNaN(p))) return null;
|
||||||
|
return [parts[0], parts[1], parts[2]];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ function makeService(): jest.Mocked<AppSettingsService> {
|
|||||||
getValue: vi.fn(),
|
getValue: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
isBookDockAutoFetchEnabled: vi.fn(),
|
isBookDockAutoFetchEnabled: vi.fn(),
|
||||||
|
isUpdateCheckEnabled: vi.fn(),
|
||||||
getAuthorsAutoEnrichmentWriteMode: vi.fn(),
|
getAuthorsAutoEnrichmentWriteMode: vi.fn(),
|
||||||
isAuthorsProviderAudnexusEnabled: vi.fn(),
|
isAuthorsProviderAudnexusEnabled: vi.fn(),
|
||||||
getUploadPattern: vi.fn(),
|
getUploadPattern: vi.fn(),
|
||||||
|
|||||||
@@ -416,4 +416,21 @@ describe('AppSettingsService', () => {
|
|||||||
expect(repo.upsert).toHaveBeenCalledOnce();
|
expect(repo.upsert).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isUpdateCheckEnabled', () => {
|
||||||
|
it('returns true by default when setting is absent', async () => {
|
||||||
|
repo.findByKey.mockResolvedValue(undefined);
|
||||||
|
expect(await service.isUpdateCheckEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when value is "false"', async () => {
|
||||||
|
repo.findByKey.mockResolvedValue({ key: 'update_check_enabled', value: 'false' } as never);
|
||||||
|
expect(await service.isUpdateCheckEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when value is "true"', async () => {
|
||||||
|
repo.findByKey.mockResolvedValue({ key: 'update_check_enabled', value: 'true' } as never);
|
||||||
|
expect(await service.isUpdateCheckEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ export class AppSettingsService {
|
|||||||
await this.repo.upsert(APP_SETTING_KEYS.METADATA_SCORE_WEIGHTS, JSON.stringify(weights));
|
await this.repo.upsert(APP_SETTING_KEYS.METADATA_SCORE_WEIGHTS, JSON.stringify(weights));
|
||||||
return weights;
|
return weights;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isUpdateCheckEnabled(): Promise<boolean> {
|
||||||
|
const row = await this.repo.findByKey(APP_SETTING_KEYS.UPDATE_CHECK_ENABLED);
|
||||||
|
return parseBooleanSetting(row?.value, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAutoFinalizeMetadataMode(value: string | undefined): BookDockAutoFinalizeMetadataMode {
|
function parseAutoFinalizeMetadataMode(value: string | undefined): BookDockAutoFinalizeMetadataMode {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const DEFAULT_APP_SETTINGS: Array<typeof schema.appSettings.$inferInsert> = [
|
|||||||
{ key: APP_SETTING_KEYS.AUTHORS_PROVIDER_AUDNEXUS_ENABLED, value: 'true' },
|
{ key: APP_SETTING_KEYS.AUTHORS_PROVIDER_AUDNEXUS_ENABLED, value: 'true' },
|
||||||
{ key: APP_SETTING_KEYS.AUDIT_RETENTION_DAYS, value: String(DEFAULT_AUDIT_RETENTION_DAYS) },
|
{ key: APP_SETTING_KEYS.AUDIT_RETENTION_DAYS, value: String(DEFAULT_AUDIT_RETENTION_DAYS) },
|
||||||
{ key: APP_SETTING_KEYS.OIDC_CONFIG, value: JSON.stringify(DEFAULT_OIDC_CONFIG) },
|
{ key: APP_SETTING_KEYS.OIDC_CONFIG, value: JSON.stringify(DEFAULT_OIDC_CONFIG) },
|
||||||
|
{ key: APP_SETTING_KEYS.UPDATE_CHECK_ENABLED, value: 'true' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
Reference in New Issue
Block a user