**Add Vitest support and migrate frontend tests**

- Added Vitest configuration (`vitest.config.mjs`) for frontend unit testing with a `jsdom` environment.
- Migrated and rewrote key frontend tests (`api.test.js`, `comiclist.test.js`, `comicreader.test.js`, `historytable.test.js`, `router.test.js`, `store.test.js`) to Vitest, improving maintainability.
- Removed `tsconfig.json` and related TypeScript loaders from `webpack` configurations (`webpack.dev.js`, `webpack.prod.js`).
- Adjusted `eslint.config.mjs` to support Vitest tests.
- Updated `package-lock.json` to include Vitest, test utilities (`jsdom`, `@vue/test-utils`), and other associated packages.
- Updated Nginx configuration for better compression on assets (`gzip` adjustments).
- Enhanced `entrypoint.sh` to include first-run library indexing (`--if-empty` argument).
- Simplified the `scan_comics` command with a dedicated test for empty database scans.

This transition improves testing workflows and backend/frontend synergy while removing unused TypeScript dependencies.
This commit is contained in:
ajurna committed 2026-10-14 10:07:14 +01:00
1 parent ce809ed1a9
commit cc557543c5
19 files changed
+2388 -586

No files matched your search

+8 -1
View File
@@ -6,6 +6,13 @@ server {
listen 80; listen 80;
# nginx compresses only text/html by default, so the bundle and CSS need explicit types.
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml application/wasm;
gzip_vary on;
location / { location / {
proxy_pass http://cbwebreader_django; proxy_pass http://cbwebreader_django;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -20,4 +27,4 @@ server {
alias /media/; alias /media/;
} }
} }
+9 -1
View File
@@ -4,7 +4,7 @@ from django.conf import settings
from django.core.management.base import BaseCommand, CommandParser from django.core.management.base import BaseCommand, CommandParser
from loguru import logger from loguru import logger
from comic.models import Directory from comic.models import ComicBook, Directory
from comic.processing import reconcile from comic.processing import reconcile
@@ -21,9 +21,17 @@ class Command(BaseCommand):
action='store_true', action='store_true',
help='Output to console', help='Output to console',
) )
parser.add_argument(
'--if-empty',
action='store_true',
help='Do nothing when the database already contains books, so it is safe to run on startup.',
)
def handle(self, *args, **options) -> None: def handle(self, *args, **options) -> None:
self.OUTPUT = options.get('out', False) self.OUTPUT = options.get('out', False)
if options.get('if_empty') and ComicBook.objects.exists():
logger.info('The database already contains books, so the startup scan is skipped.')
return
self.scan_directory() self.scan_directory()
def scan_directory(self, directory: Directory | None = None) -> None: def scan_directory(self, directory: Directory | None = None) -> None:
+23
View File
@@ -22,6 +22,29 @@ def test_scan_indexes_nested_directories(comic_volume: Path, reader_user) -> Non
assert book.page_count == 2 assert book.page_count == 2
@pytest.mark.django_db
def test_scan_if_empty_scans_an_empty_database(comic_volume: Path) -> None:
make_cbz(comic_volume / 'issue.cbz', pages=1)
call_command('scan_comics', if_empty=True)
assert models.ComicBook.objects.count() == 1
@pytest.mark.django_db
def test_scan_if_empty_skips_when_books_already_exist(comic_volume: Path) -> None:
"""Files added while the container was down are left to the cron scan."""
make_cbz(comic_volume / 'issue.cbz', pages=1)
call_command('scan_comics')
make_cbz(comic_volume / 'later.cbz', pages=1)
call_command('scan_comics', if_empty=True)
assert models.ComicBook.objects.count() == 1
call_command('scan_comics')
assert models.ComicBook.objects.count() == 2
@pytest.mark.django_db @pytest.mark.django_db
def test_scan_runs_without_any_user(comic_volume: Path) -> None: def test_scan_runs_without_any_user(comic_volume: Path) -> None:
"""The command no longer needs a user, because statuses are created when a book is read.""" """The command no longer needs a user, because statuses are created when a book is read."""
+8 -1
View File
@@ -6,6 +6,13 @@ server {
listen 80; listen 80;
# nginx compresses only text/html by default, so the bundle and CSS need explicit types.
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml application/wasm;
gzip_vary on;
location / { location / {
proxy_pass http://cbwebreader_django; proxy_pass http://cbwebreader_django;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -20,4 +27,4 @@ server {
alias /media/; alias /media/;
} }
} }
+4
View File
@@ -3,6 +3,10 @@ set -e
python manage.py migrate --settings=cbreader.settings.base python manage.py migrate --settings=cbreader.settings.base
# Index the library on the first run so the UI is not empty until cron gets to it.
# --if-empty makes this a no-op on every later start.
python manage.py scan_comics --if-empty --settings=cbreader.settings.base
python manage.py collectstatic --settings=cbreader.settings.base --noinput --clear python manage.py collectstatic --settings=cbreader.settings.base --noinput --clear
exec gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application exec gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
+17
View File
@@ -22,6 +22,23 @@ export default [
}, },
}, },
// Tests run under Vitest with a jsdom environment.
{
files: ['test/**'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
document: 'readonly',
localStorage: 'readonly',
console: 'readonly',
window: 'readonly',
HTMLElement: 'readonly',
KeyboardEvent: 'readonly',
},
},
},
// Application code is browser code bundled by webpack. // Application code is browser code bundled by webpack.
{ {
files: ['src/**'], files: ['src/**'],
+1615 -536
View File
File diff suppressed because it is too large. Load diff
+8 -3
View File
@@ -5,7 +5,8 @@
"scripts": { "scripts": {
"serve": "webpack-dev-server --config webpack.dev.js", "serve": "webpack-dev-server --config webpack.dev.js",
"build": "webpack --config webpack.prod.js", "build": "webpack --config webpack.prod.js",
"lint": "eslint ." "lint": "eslint .",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.3.1", "@fortawesome/fontawesome-svg-core": "^7.3.1",
@@ -24,18 +25,22 @@
"vuex": "^4.1.0" "vuex": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.10",
"@vue/test-utils": "^2.5.1",
"css-loader": "^7.1.5", "css-loader": "^7.1.5",
"eslint": "^10.12.0", "eslint": "^10.12.0",
"eslint-plugin-vue": "^10.11.1", "eslint-plugin-vue": "^10.11.1",
"jsdom": "^30.1.2",
"mini-css-extract-plugin": "^2.10.2", "mini-css-extract-plugin": "^2.10.2",
"style-loader": "^4.0.0", "style-loader": "^4.0.0",
"terser-webpack-plugin": "^5.6.1", "terser-webpack-plugin": "^5.6.1",
"vitest": "^5.0.4",
"vue-loader": "^17.4.2", "vue-loader": "^17.4.2",
"webpack": "^5.111.1", "webpack": "^5.111.1",
"webpack-bundle-tracker": "^3.2.4", "webpack-bundle-tracker": "^3.2.4",
"webpack-cli": "^7.2.3", "webpack-cli": "^7.2.3",
"webpack-dev-server": "^6.0.0", "webpack-dev-server": "^6.0.0"
"@eslint/js": "^10.0.1"
}, },
"browserslist": [ "browserslist": [
"defaults" "defaults"
+1 -1
View File
@@ -21,7 +21,7 @@
<script> <script>
import Reveal from "reveal.js"; import Reveal from "reveal.js";
import api from "@/api"; import api from "@/api";
import * as Hammer from 'hammerjs' import Hammer from 'hammerjs'
import ComicPaginate from "@/components/ComicPaginate"; import ComicPaginate from "@/components/ComicPaginate";
export default { export default {
+114
View File
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { api, store, router, interceptors } = vi.hoisted(() => {
const interceptors = { request: [], response: [] }
const api = {
request: vi.fn(),
interceptors: {
request: { use: (fn) => interceptors.request.push(fn) },
response: { use: (ok, err) => interceptors.response.push({ ok, err }) },
},
}
return {
api,
store: { dispatch: vi.fn(), commit: vi.fn() },
router: { push: vi.fn() },
interceptors,
}
})
vi.mock('axios', () => ({ default: { create: () => api } }))
vi.mock('@/store', () => ({ default: store }))
vi.mock('@/router', () => ({ default: router }))
import axiosApi from '@/api'
const requestInterceptor = interceptors.request[0]
const { ok: successHandler, err: errorHandler } = interceptors.response[0]
function error401(url) {
return { response: { status: 401 }, config: { url } }
}
beforeEach(() => {
document.cookie = 'csrftoken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
api.request.mockReset()
store.dispatch.mockReset()
store.commit.mockReset()
router.push.mockReset()
})
describe('request interceptor', () => {
it('uses the cookie session, so it sends no Authorization header', () => {
expect(axiosApi).toBe(api)
expect(requestInterceptor({ headers: {} }).headers).not.toHaveProperty('Authorization')
})
it('adds the CSRF header when a csrftoken cookie exists', () => {
document.cookie = 'csrftoken=abc123; path=/'
expect(requestInterceptor({ headers: {} }).headers['X-CSRFToken']).toBe('abc123')
})
it('leaves the headers untouched when there is no CSRF cookie', () => {
expect(requestInterceptor({ headers: {} }).headers).not.toHaveProperty('X-CSRFToken')
})
})
describe('response interceptor', () => {
it('passes successful responses through', async () => {
const response = { data: [1, 2] }
expect(await successHandler(response)).toBe(response)
expect(store.dispatch).not.toHaveBeenCalled()
})
it('refreshes the session and retries once after a 401', async () => {
store.dispatch.mockResolvedValue(undefined)
api.request.mockResolvedValue({ data: 'retried' })
const error = error401('/api/browse/')
expect(await errorHandler(error)).toEqual({ data: 'retried' })
expect(store.dispatch).toHaveBeenCalledWith('refreshToken')
expect(api.request).toHaveBeenCalledWith(error.config)
expect(error.config._retried).toBe(true)
})
it('does not retry a request that has already been retried', async () => {
const error = error401('/api/browse/')
error.config._retried = true
await expect(errorHandler(error)).rejects.toBe(error)
expect(store.dispatch).not.toHaveBeenCalled()
})
it('does not retry the token endpoints, which would loop', async () => {
const error = error401('/api/token/refresh/')
await expect(errorHandler(error)).rejects.toBe(error)
expect(store.dispatch).not.toHaveBeenCalled()
})
it('logs out and goes to the login page when the refresh fails', async () => {
store.dispatch.mockRejectedValue(new Error('expired'))
const error = error401('/api/browse/')
await expect(errorHandler(error)).rejects.toBe(error)
expect(store.commit).toHaveBeenCalledWith('logOut')
expect(router.push).toHaveBeenCalledWith({
name: 'login',
query: {
next: '/api/browse/',
error: 'Your session has expired. Please log in again.',
},
})
})
it('leaves other error statuses alone', async () => {
const error = { response: { status: 403 }, config: { url: '/api/browse/' } }
await expect(errorHandler(error)).rejects.toBe(error)
expect(store.dispatch).not.toHaveBeenCalled()
})
})
+133
View File
@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), put: vi.fn() }))
const store = vi.hoisted(() => ({ state: { filters: {} }, getters: { isAuthenticated: true, is_superuser: false } }))
vi.mock('@/api', () => ({ default: api }))
vi.mock('@/store', () => ({ default: store }))
import TheComicList from '@/components/TheComicList.vue'
const BOOKS = [
{ selector: 'a1', title: 'Alpha', type: 'ComicBook', finished: true, unread: false, thumbnail: '' },
{ selector: 'b2', title: 'Beta', type: 'ComicBook', finished: false, unread: true, thumbnail: '' },
{ selector: 'c3', title: 'Gamma Subfolder', type: 'Directory', thumbnail: '' },
]
function mountList(props = {}) {
return mount(TheComicList, {
props: { selector: props.selector ?? '' },
global: {
stubs: {
'font-awesome-icon': true,
ComicCard: {
name: 'ComicCard',
props: ['data'],
template: '<div class="card-stub" :data-selector="data.title">{{ data.title }}</div>',
},
},
},
})
}
beforeEach(() => {
api.get.mockReset()
api.post.mockReset()
api.put.mockReset()
api.get.mockResolvedValue({ data: [...BOOKS] })
store.state.filters = {}
})
describe('TheComicList', () => {
it('loads the home listing when it is mounted', async () => {
const wrapper = mountList()
await flushPromises()
expect(api.get).toHaveBeenCalledWith('/api/browse/')
expect(wrapper.text()).toContain('Alpha')
})
it('loads the listing for the folder it was given', async () => {
const wrapper = mountList({ selector: 'c3' })
await flushPromises()
expect(api.get).toHaveBeenCalledWith('/api/browse/c3/')
expect(wrapper.vm.comics).toHaveLength(3)
})
it('filters by name without changing the loaded data', async () => {
const wrapper = mountList()
await flushPromises()
wrapper.vm.filters.search_string = 'beta'
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Beta'])
wrapper.vm.filters.search_string = 'ga'
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Gamma Subfolder'])
})
it('filters read and unread entries separately', async () => {
const wrapper = mountList()
await flushPromises()
wrapper.vm.filters.filter_read = true
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Alpha'])
wrapper.vm.filters.filter_read = false
wrapper.vm.filters.filter_unread = true
expect(wrapper.vm.filteredComics.map((c) => c.title)).toEqual(['Beta'])
})
it('keeps the filters in the store so they survive a folder change', async () => {
const wrapper = mountList({ selector: 'c3' })
await flushPromises()
wrapper.vm.filters.search_string = 'alpha'
expect(store.state.filters.c3.search_string).toBe('alpha')
})
it('scans the folder on disk and uses the returned listing', async () => {
let resolvePost
api.post.mockImplementation(() => new Promise((resolve) => { resolvePost = resolve }))
const wrapper = mountList({ selector: 'c3' })
await flushPromises()
const button = wrapper.find('button[title="Read this folder from disk again"]')
button.trigger('click')
await wrapper.vm.$nextTick()
expect(api.post).toHaveBeenCalledWith('/api/browse/scan/', { selector: 'c3' })
expect(wrapper.vm.scanning).toBe(true)
expect(button.element.disabled).toBe(true)
resolvePost({ data: [{ selector: 'd4', title: 'Delta', type: 'ComicBook' }] })
await flushPromises()
expect(wrapper.vm.comics).toHaveLength(1)
expect(wrapper.vm.scanning).toBe(false)
})
it('marks every book in the folder, ignoring subfolders', async () => {
const wrapper = mountList()
await flushPromises()
api.put.mockResolvedValue({ data: [] })
wrapper.vm.markAll('mark_read')
await flushPromises()
expect(api.put).toHaveBeenCalledWith('/api/action/mark_read/', { selectors: ['a1', 'b2'] })
})
it('marks everything before the card that asked for it', async () => {
const wrapper = mountList()
await flushPromises()
api.put.mockResolvedValue({ data: [] })
wrapper.vm.markPreviousRead('b2')
await flushPromises()
expect(api.put).toHaveBeenCalledWith('/api/action/mark_read/', { selectors: ['a1', 'b2'] })
})
})
+167
View File
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
const api = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }))
const router = vi.hoisted(() => ({ push: vi.fn() }))
const deck = vi.hoisted(() => ({
initialize: vi.fn(() => Promise.resolve()),
slide: vi.fn(),
on: vi.fn(),
off: vi.fn(),
destroy: vi.fn(),
isFirstSlide: vi.fn(() => false),
isLastSlide: vi.fn(() => false),
}))
const hammertime = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn() }))
const reveal = vi.hoisted(() => vi.fn(() => deck))
vi.mock('@/api', () => ({ default: api }))
vi.mock('reveal.js', () => ({ default: reveal }))
vi.mock('hammerjs', () => ({ default: vi.fn(function () { return hammertime }) }))
import TheComicReader from '@/components/TheComicReader.vue'
const COMIC = {
title: 'Alpha',
last_read_page: 2,
pages: 5,
prev_comic: { route: 'browse', selector: 'p1' },
next_comic: { route: 'browse', selector: 'n1' },
}
const mounted = []
function mountReader() {
const wrapper = mount(TheComicReader, {
props: { selector: 'a1' },
global: {
mocks: { $router: router },
stubs: { 'comic-paginate': true },
},
})
mounted.push(wrapper)
return wrapper
}
beforeEach(() => {
vi.useFakeTimers()
HTMLElement.prototype.scrollIntoView = vi.fn()
api.get.mockReset()
api.put.mockReset()
api.get.mockResolvedValue({ data: { ...COMIC } })
api.put.mockResolvedValue({ data: {} })
router.push.mockReset()
reveal.mockClear()
for (const fn of Object.values(deck)) {
if (typeof fn === 'function') fn.mockClear?.()
}
for (const fn of Object.values(hammertime)) fn.mockClear()
})
afterEach(() => {
mounted.forEach((wrapper) => {
if (!wrapper.vm?.isUnmounted) wrapper.unmount()
})
mounted.length = 0
})
describe('TheComicReader', () => {
it('loads the book, starts the deck and records the page it resumed on', async () => {
const wrapper = mountReader()
await flushPromises()
expect(api.get).toHaveBeenCalledWith('/api/read/a1/')
expect(deck.initialize).toHaveBeenCalled()
expect(wrapper.vm.current_page).toBe(2)
expect(deck.slide).toHaveBeenCalledWith(2)
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 2 })
})
it('keeps the page indicator in step with the deck', async () => {
const wrapper = mountReader()
await flushPromises()
wrapper.vm.setPage(4)
expect(deck.slide).toHaveBeenCalledWith(3)
await flushPromises()
expect(wrapper.vm.paginate_page).toBe(4)
})
it('debounces arrow keys so held keys do not spam the deck', async () => {
const wrapper = mountReader()
await flushPromises()
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }))
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }))
vi.advanceTimersByTime(50)
expect(deck.slide).toHaveBeenCalledTimes(2)
expect(wrapper.vm.current_page).toBe(3)
})
it('moves to the next book when the last slide is reached', async () => {
const wrapper = mountReader()
await flushPromises()
deck.isLastSlide.mockReturnValueOnce(true)
wrapper.vm.keyPress({ key: 'ArrowRight' })
expect(router.push).toHaveBeenCalledWith({ name: 'browse', params: { selector: 'n1' } })
})
it('moves to the previous book when the first slide is reached', async () => {
const wrapper = mountReader()
await flushPromises()
deck.isFirstSlide.mockReturnValueOnce(true)
wrapper.vm.keyPress({ key: 'ArrowLeft' })
expect(router.push).toHaveBeenCalledWith({ name: 'browse', params: { selector: 'p1' } })
})
it('records the page again whenever the visible slide changes', async () => {
mountReader()
await flushPromises()
const handler = deck.on.mock.calls[0][1]
api.put.mockClear()
handler({ indexh: 4 })
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
})
it('removes every listener and the deck when it is unmounted', async () => {
const addSpy = vi.spyOn(window, 'addEventListener')
const removeSpy = vi.spyOn(window, 'removeEventListener')
const wrapper = mountReader()
await flushPromises()
expect(addSpy).toHaveBeenCalledWith('keyup', wrapper.vm.keyPressDebounce)
wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('keyup', wrapper.vm.keyPressDebounce)
expect(deck.off).toHaveBeenCalledWith('slidechanged', wrapper.vm.slideChanged)
expect(deck.destroy).toHaveBeenCalled()
expect(hammertime.off).toHaveBeenCalledWith('swipeleft')
expect(hammertime.off).toHaveBeenCalledWith('swiperight')
expect(hammertime.off).toHaveBeenCalledWith('tap')
addSpy.mockRestore()
removeSpy.mockRestore()
})
it('does not act on a key press that was debounced just before unmounting', async () => {
const wrapper = mountReader()
await flushPromises()
deck.slide.mockClear()
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }))
wrapper.unmount()
vi.advanceTimersByTime(100)
expect(deck.slide).not.toHaveBeenCalled()
})
})
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
const api = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('@/api', () => ({ default: api }))
import HistoryTable from '@/components/HistoryTable.vue'
function mountTable() {
return mount(HistoryTable, { global: { stubs: { paginate: true, 'font-awesome-icon': true } } })
}
beforeEach(() => {
vi.useFakeTimers()
api.get.mockReset()
api.get.mockResolvedValue({ data: { count: 25, results: [{ id: 1, selector: 'a1', file_name: 'Alpha', last_read_time: new Date(), unread: false, finished: true, last_read_page: 3, page_count: 5 }] } })
})
afterEach(() => {
vi.useRealTimers()
})
describe('HistoryTable', () => {
it('requests the first page when it is mounted', async () => {
const wrapper = mountTable()
await flushPromises()
expect(api.get).toHaveBeenCalledWith('/api/history/', { params: { page: 1, page_size: 10 } })
expect(wrapper.text()).toContain('Finished')
})
it('works out how many pages the result count implies', async () => {
const wrapper = mountTable()
await flushPromises()
expect(wrapper.vm.page_count).toBe(3)
wrapper.vm.page_size = 25
wrapper.vm.setPage(2)
await flushPromises()
expect(api.get).toHaveBeenLastCalledWith('/api/history/', { params: { page: 2, page_size: 25 } })
expect(wrapper.vm.page_count).toBe(1)
})
it('includes the search text once there is one', async () => {
const wrapper = mountTable()
await flushPromises()
wrapper.vm.search_text = 'alpha'
wrapper.vm.setPage(1)
await flushPromises()
expect(api.get).toHaveBeenLastCalledWith('/api/history/', {
params: { page: 1, page_size: 10, search_text: 'alpha' },
})
})
it('debounces typing so one burst of keystrokes makes one request', async () => {
const wrapper = mountTable()
await flushPromises()
expect(api.get).toHaveBeenCalledTimes(1)
wrapper.vm.debounceInput()
wrapper.vm.debounceInput()
wrapper.vm.debounceInput()
vi.advanceTimersByTime(500)
await flushPromises()
expect(api.get).toHaveBeenCalledTimes(2)
})
it('does not fire a pending debounce after the table is removed', async () => {
const wrapper = mountTable()
await flushPromises()
wrapper.vm.debounceInput()
wrapper.unmount()
vi.advanceTimersByTime(1000)
await flushPromises()
expect(api.get).toHaveBeenCalledTimes(1)
})
it('goes back to page one when the page number is no longer valid', async () => {
api.get.mockRejectedValueOnce({ response: { data: { detail: 'Invalid page.' } } })
const wrapper = mountTable()
await flushPromises()
expect(wrapper.vm.page).toBe(1)
expect(api.get).toHaveBeenCalledTimes(2)
})
it('shows the reading status for each entry', async () => {
const wrapper = mountTable()
await flushPromises()
expect(wrapper.vm.get_status({ unread: true })).toBe('Unread')
expect(wrapper.vm.get_status({ unread: null })).toBe('Unread')
expect(wrapper.vm.get_status({ unread: false, finished: true })).toBe('Finished')
expect(wrapper.vm.get_status({ unread: false, finished: false, last_read_page: 2, page_count: 5 })).toBe('2 / 5')
})
})
+50
View File
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/api', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
import store from '@/store'
import router from '@/router'
beforeEach(() => {
localStorage.clear()
store.commit('logOut')
})
describe('navigation guards', () => {
it('redirects an anonymous user to the login page', async () => {
await router.push('/account')
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.error).toBe('Please log in to access this page')
})
it('keeps the requested path so the user returns there after logging in', async () => {
await router.push('/history')
expect(router.currentRoute.value.query.next).toBe('/history')
})
it('allows an authenticated non-admin to reach the history page', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
await router.push('/history')
expect(router.currentRoute.value.name).toBe('history')
})
it('still requires admin for the user page', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
await router.push('/user')
expect(router.currentRoute.value.name).toBe('login')
})
it('allows an admin on the user page', async () => {
store.commit('updateUser', { id: 1, is_superuser: true })
await router.push('/user')
expect(router.currentRoute.value.name).toBe('user')
})
})
+103
View File
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { api, router } = vi.hoisted(() => ({
api: { get: vi.fn(), post: vi.fn() },
router: { currentRoute: { value: { query: {}, fullPath: '/account' } }, push: vi.fn() },
}))
vi.mock('@/api', () => ({ default: api }))
vi.mock('@/router', () => ({ default: router }))
import store from '@/store'
beforeEach(() => {
localStorage.clear()
store.commit('logOut')
api.get.mockReset()
api.post.mockReset()
router.push.mockReset()
})
describe('session state', () => {
it('is not authenticated when there is no stored profile', () => {
expect(store.getters.isAuthenticated).toBe(false)
expect(store.getters.is_superuser).toBe(false)
})
it('keeps the profile in localStorage so a reload is not a blank page', () => {
store.commit('updateUser', { id: 1, is_superuser: false })
expect(store.getters.isAuthenticated).toBe(true)
expect(JSON.parse(localStorage.getItem('u'))).toEqual({ id: 1, is_superuser: false })
})
it('logOut clears both the profile and localStorage', () => {
store.commit('updateUser', { id: 1, is_superuser: true })
store.commit('logOut')
expect(store.getters.isAuthenticated).toBe(false)
expect(localStorage.getItem('u')).toBeNull()
})
})
describe('restoreSession', () => {
it('refreshes the stored profile from the cookie session', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
api.get.mockResolvedValue({ data: { id: 1, is_superuser: true } })
await store.dispatch('restoreSession')
expect(api.get).toHaveBeenCalledWith('/api/account')
expect(store.getters.is_superuser).toBe(true)
})
it('logs out when the cookie no longer identifies the user', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
api.get.mockRejectedValue({ response: { status: 401 } })
await store.dispatch('restoreSession')
expect(store.getters.isAuthenticated).toBe(false)
expect(router.push).toHaveBeenCalledWith({ name: 'login' })
})
it('does nothing when there is no stored profile', async () => {
await store.dispatch('restoreSession')
expect(api.get).not.toHaveBeenCalled()
})
})
describe('token handling', () => {
it('rejects and logs out when the refresh cookie is no longer valid', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
api.post.mockRejectedValue({ response: { status: 401 } })
await expect(store.dispatch('refreshToken')).rejects.toBeTruthy()
expect(store.getters.isAuthenticated).toBe(false)
expect(router.push).toHaveBeenCalledWith(expect.objectContaining({ name: 'login' }))
})
it('does not call the API when there is no session', async () => {
await expect(store.dispatch('refreshToken')).rejects.toBeTruthy()
expect(api.post).not.toHaveBeenCalled()
})
it('does not blacklist a token for a session that does not exist', async () => {
await store.dispatch('logout')
expect(api.post).not.toHaveBeenCalled()
expect(store.getters.isAuthenticated).toBe(false)
})
it('blacklists the token when a session existed', async () => {
store.commit('updateUser', { id: 1, is_superuser: false })
api.post.mockResolvedValue({ data: {} })
await store.dispatch('logout')
expect(api.post).toHaveBeenCalledWith('/api/token/blacklist/', {})
expect(store.getters.isAuthenticated).toBe(false)
})
})
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"declaration": false,
"esModuleInterop": true,
"experimentalDecorators": true,
"module": "es2015",
"moduleResolution": "node",
"noImplicitAny": false,
"noLib": false,
"sourceMap": true,
"strict": true,
"strictPropertyInitialization": false,
"suppressImplicitAnyIndexErrors": true,
"target": "es2015",
"baseUrl": "./src"
},
"exclude": [
"./node_modules"
],
"include": [
"./src/**/*.ts",
"./src/**/*.vue"
]
}
+18
View File
@@ -0,0 +1,18 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'src')
export default defineConfig({
plugins: [vue()],
// The source imports components without the .vue extension (e.g. '@/views/BrowseView'),
// so mirror the extension list used by frontend/webpack.dev.js.
resolve: { extensions: ['.ts', '.js', '.vue', '.json'] },
test: {
environment: 'jsdom',
alias: { '@': SRC },
},
})
-8
View File
@@ -24,14 +24,6 @@ module.exports = () => {
test: /\.vue$/, test: /\.vue$/,
use: 'vue-loader' use: 'vue-loader'
}, },
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: true
}
},
{ {
test: /\.css$/, test: /\.css$/,
use: [ use: [
+3 -9
View File
@@ -11,7 +11,9 @@ module.exports = (env = {}) => {
return { return {
mode: 'production', mode: 'production',
devtool: 'hidden-source-map', // No source maps in production: they are not served to browsers, but collectstatic
// copies them into /static/ so browser devtools would pull ~3.3 MiB of maps.
devtool: false,
entry: path.resolve(__dirname, './src/main.js'), entry: path.resolve(__dirname, './src/main.js'),
output: { output: {
path: path.resolve(__dirname, './dist/bundles/'), path: path.resolve(__dirname, './dist/bundles/'),
@@ -22,14 +24,6 @@ module.exports = (env = {}) => {
test: /\.vue$/, test: /\.vue$/,
use: 'vue-loader' use: 'vue-loader'
}, },
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
appendTsSuffixTo: [/\.vue$/],
transpileOnly: true
}
},
{ {
test: /\.css$/, test: /\.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"], use: [MiniCssExtractPlugin.loader, "css-loader"],