Compare commits
14 Commits
fix/issue-
...
5512790d5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5512790d5a | ||
|
|
a158048f45 | ||
|
|
ac999066dd | ||
|
|
8b25a5b940 | ||
|
|
265b4f1484 | ||
|
|
bfc3a17676 | ||
|
|
eb54a98ea2 | ||
|
|
3fcdfa85f1 | ||
|
|
cd1c0b210e | ||
|
|
a239c16c31 | ||
|
|
8a8205ad8d | ||
|
|
0430383e1c | ||
|
|
e2d74ff880 | ||
|
|
586eea009b |
@@ -39,6 +39,12 @@ jobs:
|
||||
- name: Run unit and component tests
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
env:
|
||||
TZ: Europe/Berlin
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
@@ -74,6 +80,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ public class CommentController {
|
||||
// ─── Block (transcription) comments ────────────────────────────────────────
|
||||
|
||||
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
||||
public List<DocumentComment> getBlockComments(@PathVariable UUID blockId) {
|
||||
public List<DocumentComment> getBlockComments(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId) {
|
||||
return commentService.getCommentsForBlock(blockId);
|
||||
}
|
||||
|
||||
@@ -48,6 +50,7 @@ public class CommentController {
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
public DocumentComment replyToBlockComment(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId,
|
||||
@PathVariable UUID commentId,
|
||||
@RequestBody CreateCommentDTO dto,
|
||||
Authentication authentication) {
|
||||
|
||||
@@ -88,7 +88,8 @@ public class AppUser {
|
||||
};
|
||||
|
||||
public static String computeColor(UUID id) {
|
||||
return PALETTE[Math.abs(id.hashCode()) % PALETTE.length];
|
||||
// Math.floorMod avoids the Integer.MIN_VALUE overflow trap in Math.abs(hashCode())
|
||||
return PALETTE[Math.floorMod(id.hashCode(), PALETTE.length)];
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
|
||||
@@ -271,9 +271,10 @@ public class UserService {
|
||||
|
||||
@Transactional
|
||||
public UserGroup createGroup(GroupDTO dto) {
|
||||
UserGroup group = new UserGroup();
|
||||
group.setName(dto.getName());
|
||||
group.setPermissions(dto.getPermissions());
|
||||
UserGroup group = UserGroup.builder()
|
||||
.name(dto.getName())
|
||||
.permissions(dto.getPermissions() != null ? dto.getPermissions() : new HashSet<>())
|
||||
.build();
|
||||
return groupRepository.save(group);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Remove duplicate (group_id, permission) rows that accumulated without a UNIQUE constraint.
|
||||
-- Keeps the row with the smallest ctid (earliest physical insertion order).
|
||||
DELETE FROM group_permissions a
|
||||
USING group_permissions b
|
||||
WHERE a.ctid < b.ctid
|
||||
AND a.group_id = b.group_id
|
||||
AND a.permission = b.permission;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Add NOT NULL and PRIMARY KEY to group_permissions.
|
||||
-- Requires V63 to have run first (no duplicates can remain).
|
||||
--
|
||||
-- After this migration, future seed migrations can use:
|
||||
-- INSERT INTO group_permissions ... ON CONFLICT DO NOTHING
|
||||
-- instead of the INSERT ... WHERE NOT EXISTS pattern used before V64.
|
||||
ALTER TABLE group_permissions
|
||||
ALTER COLUMN permission SET NOT NULL;
|
||||
|
||||
ALTER TABLE group_permissions
|
||||
ADD CONSTRAINT pk_group_permissions PRIMARY KEY (group_id, permission);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Promote the de-facto unique constraint on transcription_block_mentioned_persons to a named PK.
|
||||
-- uq_tbmp_block_person (added in V57) is backed by a B-tree index identical to a PK;
|
||||
-- this rename makes the naming convention explicit (pk_* vs uq_*).
|
||||
ALTER TABLE transcription_block_mentioned_persons
|
||||
DROP CONSTRAINT uq_tbmp_block_person;
|
||||
|
||||
ALTER TABLE transcription_block_mentioned_persons
|
||||
ADD CONSTRAINT pk_tbmp PRIMARY KEY (block_id, person_id);
|
||||
@@ -399,6 +399,68 @@ class MigrationIntegrationTest {
|
||||
AND dc.annotation_id IS NOT NULL
|
||||
""";
|
||||
|
||||
// ─── V63+V64: group_permissions dedup + primary key ──────────────────────
|
||||
|
||||
@Test
|
||||
void v64_pk_group_permissions_exists() {
|
||||
Integer count = jdbc.queryForObject(
|
||||
"""
|
||||
SELECT COUNT(*) FROM pg_catalog.pg_constraint c
|
||||
JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
|
||||
WHERE t.relname = 'group_permissions'
|
||||
AND c.conname = 'pk_group_permissions'
|
||||
AND c.contype = 'p'
|
||||
""",
|
||||
Integer.class);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void v64_permission_column_isNotNullable() {
|
||||
Integer count = jdbc.queryForObject(
|
||||
"""
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'group_permissions'
|
||||
AND column_name = 'permission'
|
||||
AND is_nullable = 'NO'
|
||||
""",
|
||||
Integer.class);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
void v64_rejectsDuplicateGroupPermission() {
|
||||
UUID groupId = createUserGroup("DuplicateTestGroup-" + UUID.randomUUID());
|
||||
try {
|
||||
jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
jdbc.update("INSERT INTO group_permissions (group_id, permission) VALUES (?, 'READ_ALL')", groupId)
|
||||
).isInstanceOf(DataIntegrityViolationException.class);
|
||||
} finally {
|
||||
jdbc.update("DELETE FROM group_permissions WHERE group_id = ?", groupId);
|
||||
jdbc.update("DELETE FROM user_groups WHERE id = ?", groupId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── V65: tbmp UNIQUE promoted to PRIMARY KEY ─────────────────────────────
|
||||
|
||||
@Test
|
||||
void v65_pk_tbmp_exists() {
|
||||
Integer count = jdbc.queryForObject(
|
||||
"""
|
||||
SELECT COUNT(*) FROM pg_catalog.pg_constraint c
|
||||
JOIN pg_catalog.pg_class t ON c.conrelid = t.oid
|
||||
WHERE t.relname = 'transcription_block_mentioned_persons'
|
||||
AND c.conname = 'pk_tbmp'
|
||||
AND c.contype = 'p'
|
||||
""",
|
||||
Integer.class);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private UUID createPerson(String firstName, String lastName) {
|
||||
@@ -482,4 +544,10 @@ class MigrationIntegrationTest {
|
||||
""", id, recipientId, docId, commentId);
|
||||
return id;
|
||||
}
|
||||
|
||||
private UUID createUserGroup(String name) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("INSERT INTO user_groups (id, name) VALUES (?, ?)", id, name);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,14 @@ class CommentControllerTest {
|
||||
|
||||
// ─── Block comment endpoints ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getBlockComments_returns400_when_documentId_is_not_a_UUID() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(get("/api/documents/NOT-A-UUID/transcription-blocks/" + blockId + "/comments"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getBlockComments_returns200() throws Exception {
|
||||
@@ -115,6 +123,15 @@ class CommentControllerTest {
|
||||
|
||||
// ─── Block reply endpoints ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
|
||||
+ "/comments/" + COMMENT_ID + "/replies")
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
|
||||
@@ -35,4 +35,15 @@ class AppUserTest {
|
||||
.count();
|
||||
assertThat(distinct).isGreaterThan(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void computeColor_returnsValidPaletteColorForIntegerMinValueHash() {
|
||||
// UUID "80000000-0000-0000-0000-000000000000" has hashCode() == Integer.MIN_VALUE.
|
||||
// Math.abs(Integer.MIN_VALUE) overflows back to Integer.MIN_VALUE (negative), making
|
||||
// Math.abs(hashCode()) % n unsafe for palette sizes that don't evenly divide MIN_VALUE.
|
||||
// Math.floorMod eliminates this edge case entirely.
|
||||
UUID minHashId = UUID.fromString("80000000-0000-0000-0000-000000000000");
|
||||
assertThat(minHashId.hashCode()).isEqualTo(Integer.MIN_VALUE);
|
||||
assertThat(EXPECTED_PALETTE).contains(AppUser.computeColor(minHashId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,4 +902,18 @@ class UserServiceTest {
|
||||
assertThat(result.getName()).isEqualTo("Familie");
|
||||
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
|
||||
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
|
||||
dto.setName("Leser");
|
||||
dto.setPermissions(null);
|
||||
|
||||
UserGroup saved = UserGroup.builder().id(UUID.randomUUID()).name("Leser").build();
|
||||
when(groupRepository.save(any())).thenReturn(saved);
|
||||
|
||||
userService.createGroup(dto);
|
||||
|
||||
verify(groupRepository).save(argThat(g -> g.getPermissions() != null && g.getPermissions().isEmpty()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Props {
|
||||
restrictToCorrespondentsOf?: string;
|
||||
excludePersonId?: string;
|
||||
badge?: 'additive' | 'replace';
|
||||
resetKey?: number;
|
||||
onchange?: (value: string) => void;
|
||||
onfocused?: () => void;
|
||||
}
|
||||
@@ -39,17 +40,20 @@ let {
|
||||
restrictToCorrespondentsOf,
|
||||
excludePersonId,
|
||||
badge,
|
||||
resetKey = 0,
|
||||
onchange,
|
||||
onfocused
|
||||
}: Props = $props();
|
||||
|
||||
// searchTerm must be both prop-derived AND locally writable (user typing), so $state +
|
||||
// $effect is the correct pattern here — writable $derived is read-only and won't work.
|
||||
// eslint-disable-next-line svelte/prefer-writable-derived
|
||||
let searchTerm = $state(initialName);
|
||||
|
||||
// Sync display text when the selected person changes externally (e.g. swap, navigation).
|
||||
// Sync display text when initialName changes OR when resetKey increments (navigation reset).
|
||||
// resetKey is incremented by the page on every SvelteKit navigation so that a manually-typed
|
||||
// term that was never committed (no person selected) gets cleared even if initialName stays ''.
|
||||
$effect(() => {
|
||||
void resetKey;
|
||||
searchTerm = initialName;
|
||||
});
|
||||
|
||||
|
||||
@@ -270,6 +270,33 @@ describe('PersonTypeahead – correspondent mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resetKey ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – resetKey', () => {
|
||||
// Note: rerender() in vitest-browser-svelte causes a full re-mount, not an in-place prop
|
||||
// update. This is a smoke test — the $effect(resetKey) path that fires during SvelteKit
|
||||
// navigation (prop update on a live instance) cannot be isolated at this level.
|
||||
it('clears a manually-typed term when resetKey changes even if initialName stays empty', async () => {
|
||||
mockFetchWithPersons([]);
|
||||
const { rerender } = render(PersonTypeahead, {
|
||||
name: 'senderId',
|
||||
label: 'Absender',
|
||||
initialName: '',
|
||||
resetKey: 0
|
||||
});
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
|
||||
// User types something without selecting a person
|
||||
await input.fill('Max');
|
||||
await waitForDebounce();
|
||||
await expect.element(input).toHaveValue('Max');
|
||||
|
||||
// Navigation resets: initialName stays '', but resetKey increments
|
||||
await rerender({ name: 'senderId', label: 'Absender', initialName: '', resetKey: 1 });
|
||||
await expect.element(input).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Click outside ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – click outside', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { isoToGerman, handleGermanDateInput, germanToIso } from '$lib/shared/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
@@ -24,6 +25,16 @@ let {
|
||||
|
||||
let display = $state(isoToGerman(value ?? ''));
|
||||
|
||||
// Re-derive display when value changes externally (e.g. timeline drag, reset nav).
|
||||
// Guard prevents overwriting while the user is mid-typing a partial date:
|
||||
// germanToIso returns '' for partial input, matching value '' → no re-derive.
|
||||
$effect(() => {
|
||||
const externalIso = value ?? '';
|
||||
if (germanToIso(untrack(() => display)) !== externalIso) {
|
||||
display = isoToGerman(externalIso);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Validation helper ────────────────────────────────────────────────────
|
||||
function isCalendarValid(iso: string): boolean {
|
||||
if (!iso) return false;
|
||||
|
||||
@@ -183,6 +183,26 @@ describe('DateInput – clearing the date', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── External value changes ───────────────────────────────────────────────────
|
||||
|
||||
describe('DateInput – external value changes', () => {
|
||||
it('clears display when value prop is reset to empty externally', async () => {
|
||||
const { rerender } = render(DateInput, { value: '1920-01-01' });
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toHaveValue('01.01.1920');
|
||||
await rerender({ value: '' });
|
||||
await expect.element(input).toHaveValue('');
|
||||
});
|
||||
|
||||
it('updates display when value prop changes to a new date externally', async () => {
|
||||
const { rerender } = render(DateInput, { value: '1920-01-01' });
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toHaveValue('01.01.1920');
|
||||
await rerender({ value: '1945-05-08' });
|
||||
await expect.element(input).toHaveValue('08.05.1945');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Hidden input ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DateInput – hidden input for form submission', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ let {
|
||||
showAdvanced = $bindable(false),
|
||||
initialSenderName = '',
|
||||
initialReceiverName = '',
|
||||
navKey = 0,
|
||||
isLoading = false,
|
||||
onSearch,
|
||||
onSearchImmediate,
|
||||
@@ -39,6 +40,7 @@ let {
|
||||
showAdvanced?: boolean;
|
||||
initialSenderName?: string;
|
||||
initialReceiverName?: string;
|
||||
navKey?: number;
|
||||
isLoading?: boolean;
|
||||
onSearch: () => void;
|
||||
onSearchImmediate?: () => void;
|
||||
@@ -197,6 +199,7 @@ $effect(() => {
|
||||
label={m.docs_filter_label_sender()}
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
resetKey={navKey}
|
||||
onchange={onSearch}
|
||||
/>
|
||||
</div>
|
||||
@@ -212,6 +215,7 @@ $effect(() => {
|
||||
label={m.docs_filter_label_receivers()}
|
||||
bind:value={receiverId}
|
||||
initialName={initialReceiverName}
|
||||
resetKey={navKey}
|
||||
onchange={onSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,20 @@ import { createApiClient } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
async function resolvePersonName(id: string, fetch: typeof globalThis.fetch): Promise<string> {
|
||||
if (!UUID_RE.test(id)) return '';
|
||||
try {
|
||||
const res = await fetch(`/api/persons/${id}`);
|
||||
if (!res.ok) return '';
|
||||
const person = await res.json();
|
||||
return person.displayName ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
|
||||
@@ -34,25 +48,30 @@ export async function load({ url, fetch }) {
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
let result;
|
||||
let initialSenderName = '';
|
||||
let initialReceiverName = '';
|
||||
try {
|
||||
result = await api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
senderId: senderId || undefined,
|
||||
receiverId: receiverId || undefined,
|
||||
tag: tags.length ? tags : undefined,
|
||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||
sort,
|
||||
dir: dir || undefined,
|
||||
page,
|
||||
size: PAGE_SIZE
|
||||
[result, [initialSenderName, initialReceiverName]] = await Promise.all([
|
||||
api.GET('/api/documents/search', {
|
||||
params: {
|
||||
query: {
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
senderId: senderId || undefined,
|
||||
receiverId: receiverId || undefined,
|
||||
tag: tags.length ? tags : undefined,
|
||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||
sort,
|
||||
dir: dir || undefined,
|
||||
page,
|
||||
size: PAGE_SIZE
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}),
|
||||
Promise.all([resolvePersonName(senderId, fetch), resolvePersonName(receiverId, fetch)])
|
||||
]);
|
||||
} catch {
|
||||
return {
|
||||
items: [] as DocumentSearchItem[],
|
||||
@@ -65,6 +84,8 @@ export async function load({ url, fetch }) {
|
||||
to,
|
||||
senderId,
|
||||
receiverId,
|
||||
initialSenderName: '',
|
||||
initialReceiverName: '',
|
||||
tags,
|
||||
sort,
|
||||
dir,
|
||||
@@ -94,6 +115,8 @@ export async function load({ url, fetch }) {
|
||||
to,
|
||||
senderId,
|
||||
receiverId,
|
||||
initialSenderName,
|
||||
initialReceiverName,
|
||||
tags,
|
||||
sort,
|
||||
dir,
|
||||
|
||||
@@ -22,6 +22,9 @@ let from = $state(untrack(() => data.from || ''));
|
||||
let to = $state(untrack(() => data.to || ''));
|
||||
let senderId = $state(untrack(() => data.senderId || ''));
|
||||
let receiverId = $state(untrack(() => data.receiverId || ''));
|
||||
let initialSenderName = $state(untrack(() => data.initialSenderName ?? ''));
|
||||
let initialReceiverName = $state(untrack(() => data.initialReceiverName ?? ''));
|
||||
let navKey = $state(0);
|
||||
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
|
||||
untrack(() => (data.tags || []).map((name: string) => ({ name })))
|
||||
);
|
||||
@@ -207,12 +210,17 @@ async function editAllMatching() {
|
||||
|
||||
// Keep local filter state in sync with server data after navigation completes.
|
||||
// Guard q: skip overwrite while the user is actively typing.
|
||||
// navKey increments on every navigation so PersonTypeahead clears manually-typed
|
||||
// terms even when initialSenderName/initialReceiverName stays '' across navigations.
|
||||
$effect(() => {
|
||||
if (!qFocused) q = data.q || '';
|
||||
from = data.from || '';
|
||||
to = data.to || '';
|
||||
senderId = data.senderId || '';
|
||||
receiverId = data.receiverId || '';
|
||||
initialSenderName = data.initialSenderName ?? '';
|
||||
initialReceiverName = data.initialReceiverName ?? '';
|
||||
untrack(() => navKey++);
|
||||
tagNames = (data.tags || []).map((name: string) => ({ name }));
|
||||
sort = data.sort || 'DATE';
|
||||
dir = data.dir || 'desc';
|
||||
@@ -247,6 +255,9 @@ $effect(() => {
|
||||
bind:dir={dir}
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
initialSenderName={initialSenderName}
|
||||
initialReceiverName={initialReceiverName}
|
||||
navKey={navKey}
|
||||
isLoading={navigating.to !== null}
|
||||
onSearch={handleTextSearch}
|
||||
onSearchImmediate={handleImmediateSearch}
|
||||
|
||||
@@ -167,3 +167,72 @@ describe('documents page load — network error fallback', () => {
|
||||
expect(result.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── person name resolution ───────────────────────────────────────────────────
|
||||
|
||||
describe('documents page load — person name resolution', () => {
|
||||
function makeSearchMock() {
|
||||
const mockGet = vi.fn().mockResolvedValue({
|
||||
response: { ok: true, status: 200 },
|
||||
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||
});
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
}
|
||||
|
||||
it('returns initialSenderName from person lookup when senderId is a valid UUID', async () => {
|
||||
makeSearchMock();
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ displayName: 'Max Mustermann' })
|
||||
});
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.initialSenderName).toBe('Max Mustermann');
|
||||
});
|
||||
|
||||
it('returns initialReceiverName from person lookup when receiverId is a valid UUID', async () => {
|
||||
makeSearchMock();
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ displayName: 'Anna Musterfrau' })
|
||||
});
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.initialReceiverName).toBe('Anna Musterfrau');
|
||||
});
|
||||
|
||||
it('returns empty string when senderId is not a valid UUID', async () => {
|
||||
makeSearchMock();
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ senderId: 'not-a-uuid' }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.initialSenderName).toBe('');
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/persons/'));
|
||||
});
|
||||
|
||||
it('returns empty string when person fetch returns 404', async () => {
|
||||
makeSearchMock();
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
||||
fetch: mockFetch as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.initialSenderName).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ function makeData(overrides: Record<string, unknown> = {}) {
|
||||
to: '',
|
||||
senderId: '',
|
||||
receiverId: '',
|
||||
initialSenderName: '',
|
||||
initialReceiverName: '',
|
||||
tags: [],
|
||||
sort: 'DATE',
|
||||
dir: 'desc',
|
||||
@@ -136,6 +138,22 @@ describe('documents page — URL building', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sender / receiver name display ──────────────────────────────────────────
|
||||
|
||||
describe('documents page — sender/receiver display', () => {
|
||||
it('pre-fills sender typeahead from initialSenderName when senderId filter is active', async () => {
|
||||
render(Page, {
|
||||
data: makeData({
|
||||
senderId: '11111111-1111-1111-1111-111111111111',
|
||||
initialSenderName: 'Max Mustermann'
|
||||
})
|
||||
});
|
||||
// Advanced filters are auto-shown when senderId is set
|
||||
const inputs = page.getByPlaceholder('Namen tippen...');
|
||||
await expect.element(inputs.first()).toHaveValue('Max Mustermann');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Timeline density widget wiring (#385) ────────────────────────────────────
|
||||
|
||||
describe('documents page — timeline density widget', () => {
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
|
||||
// before prerendered HTML is visible.
|
||||
export const prerender = true;
|
||||
|
||||
@@ -6,7 +6,10 @@ const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
kit: { adapter: adapter() }
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
prerender: { entries: ['/hilfe/transkription'] }
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
12
runner-config.yaml
Normal file
12
runner-config.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
# runner-config.yaml — only the relevant section
|
||||
container:
|
||||
# passed as DOCKER_HOST inside the job container
|
||||
docker_host: "unix:///var/run/docker.sock"
|
||||
# whitelists the socket path so workflows can mount it
|
||||
valid_volumes:
|
||||
- "/var/run/docker.sock"
|
||||
# appended to `docker run` when the runner spawns a job container
|
||||
options: "-v /var/run/docker.sock:/var/run/docker.sock"
|
||||
# keep network mode default (bridge) — Testcontainers handles its own networking
|
||||
force_pull: false
|
||||
|
||||
Reference in New Issue
Block a user