mirror of
https://github.com/ajurna/cbwebreader.git
synced 2026-11-02 10:36:55 +00:00
**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:
1 parent
ce809ed1a9
commit
cc557543c5
19 files changed
+2386
-584
No files matched your search
@@ -6,6 +6,13 @@ server {
|
||||
|
||||
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 / {
|
||||
proxy_pass http://cbwebreader_django;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandParser
|
||||
from loguru import logger
|
||||
|
||||
from comic.models import Directory
|
||||
from comic.models import ComicBook, Directory
|
||||
from comic.processing import reconcile
|
||||
|
||||
|
||||
@@ -21,9 +21,17 @@ class Command(BaseCommand):
|
||||
action='store_true',
|
||||
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:
|
||||
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()
|
||||
|
||||
def scan_directory(self, directory: Directory | None = None) -> None:
|
||||
|
||||
@@ -22,6 +22,29 @@ def test_scan_indexes_nested_directories(comic_volume: Path, reader_user) -> Non
|
||||
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
|
||||
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."""
|
||||
|
||||
@@ -6,6 +6,13 @@ server {
|
||||
|
||||
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 / {
|
||||
proxy_pass http://cbwebreader_django;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -3,6 +3,10 @@ set -e
|
||||
|
||||
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
|
||||
|
||||
exec gunicorn --workers 3 --bind 0.0.0.0:8000 cbreader.wsgi:application
|
||||
@@ -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.
|
||||
{
|
||||
files: ['src/**'],
|
||||
|
||||
Generated
+1615
-536
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"serve": "webpack-dev-server --config webpack.dev.js",
|
||||
"build": "webpack --config webpack.prod.js",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||
@@ -24,18 +25,22 @@
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"css-loader": "^7.1.5",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"jsdom": "^30.1.2",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"style-loader": "^4.0.0",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"vitest": "^5.0.4",
|
||||
"vue-loader": "^17.4.2",
|
||||
"webpack": "^5.111.1",
|
||||
"webpack-bundle-tracker": "^3.2.4",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-dev-server": "^6.0.0",
|
||||
"@eslint/js": "^10.0.1"
|
||||
"webpack-dev-server": "^6.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"defaults"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<script>
|
||||
import Reveal from "reveal.js";
|
||||
import api from "@/api";
|
||||
import * as Hammer from 'hammerjs'
|
||||
import Hammer from 'hammerjs'
|
||||
import ComicPaginate from "@/components/ComicPaginate";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'] })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
})
|
||||
@@ -24,14 +24,6 @@ module.exports = () => {
|
||||
test: /\.vue$/,
|
||||
use: 'vue-loader'
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
appendTsSuffixTo: [/\.vue$/],
|
||||
transpileOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
|
||||
@@ -11,7 +11,9 @@ module.exports = (env = {}) => {
|
||||
return {
|
||||
|
||||
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'),
|
||||
output: {
|
||||
path: path.resolve(__dirname, './dist/bundles/'),
|
||||
@@ -22,14 +24,6 @@ module.exports = (env = {}) => {
|
||||
test: /\.vue$/,
|
||||
use: 'vue-loader'
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
appendTsSuffixTo: [/\.vue$/],
|
||||
transpileOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [MiniCssExtractPlugin.loader, "css-loader"],
|
||||
|
||||
Reference in new issue
Block a user