diff --git a/integ_tests/common.py b/integ_tests/common.py index 53b8ae38..8a7b503b 100644 --- a/integ_tests/common.py +++ b/integ_tests/common.py @@ -59,6 +59,7 @@ collection_by_map = true collection_by_token = true permit_create_token = true permit_create_map = true +permit_properties_overlay = true """ ) diff --git a/integ_tests/test_delete.py b/integ_tests/test_delete.py new file mode 100644 index 00000000..20357adb --- /dev/null +++ b/integ_tests/test_delete.py @@ -0,0 +1,77 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 Max Berger +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Integration tests for delete collection scene +""" + +import pathlib +from typing import Any, Generator + +import pytest +from playwright.sync_api import Page, expect + +from integ_tests.common import create_collection, login, start_radicale_server + + +@pytest.fixture +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) + + +def test_delete_wrong_confirmation(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + + # Open delete scene + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="delete"]', force=True) + + # Input wrong confirmation + page.fill('#deletecollectionscene input[data-name="confirmationtxt"]', "foo") + page.click('#deletecollectionscene button[data-name="delete"]') + + # Check for error message + error_locator = page.locator('#deletecollectionscene span[data-name="error"]') + expect(error_locator).to_be_visible() + expect(error_locator).to_contain_text( + "Please type DELETE in the confirmation field" + ) + + # Scene should still be visible + expect(page.locator("#deletecollectionscene")).to_be_visible() + + +def test_delete_correct_confirmation(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + + # Verify collection exists + expect(page.locator("article:not(.hidden)")).to_have_count(1) + + # Open delete scene + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="delete"]', force=True) + + # Input correct confirmation + page.fill('#deletecollectionscene input[data-name="confirmationtxt"]', "DELETE") + page.click('#deletecollectionscene button[data-name="delete"]') + + # Verify collection is gone + expect(page.locator("article:not(.hidden)")).to_have_count(0) + + # Scene should be hidden + expect(page.locator("#deletecollectionscene")).to_be_hidden() diff --git a/integ_tests/test_edit.py b/integ_tests/test_edit.py new file mode 100644 index 00000000..39e6631b --- /dev/null +++ b/integ_tests/test_edit.py @@ -0,0 +1,82 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 Max Berger +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Integration tests for edit collection scene +""" + +import pathlib +from typing import Any, Generator + +import pytest +from playwright.sync_api import Page, expect + +from integ_tests.common import create_collection, login, start_radicale_server + + +@pytest.fixture +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) + + +def test_edit_save(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + + # Get original values + article = page.locator("article:not(.hidden)") + + # Open edit scene + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="edit"]', force=True) + + # Update title and description + new_title = "Updated Title" + new_description = "Updated Description" + page.fill('#editcollectionscene input[data-name="displayname"]', new_title) + page.fill('#editcollectionscene input[data-name="description"]', new_description) + page.click('#editcollectionscene button[data-name="submit"]') + + # Verify updates in the list + expect(article.locator('[data-name="title"]')).to_have_text(new_title) + expect(article.locator('[data-name="description"]')).to_have_text(new_description) + + +def test_edit_cancel(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + + # Get original values + article = page.locator("article:not(.hidden)") + original_title = article.locator('[data-name="title"]').text_content() + original_description = article.locator('[data-name="description"]').text_content() + + # Open edit scene + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="edit"]', force=True) + + # Update title and description but cancel + page.fill('#editcollectionscene input[data-name="displayname"]', "Changed Title") + page.fill( + '#editcollectionscene input[data-name="description"]', "Changed Description" + ) + page.click('#editcollectionscene button[data-name="cancel"]') + + # Verify values remain unchanged + expect(article.locator('[data-name="title"]')).to_have_text(original_title) + expect(article.locator('[data-name="description"]')).to_have_text( + original_description + ) diff --git a/integ_tests/test_sharing.py b/integ_tests/test_sharing.py index fa87a491..f1d53c58 100644 --- a/integ_tests/test_sharing.py +++ b/integ_tests/test_sharing.py @@ -112,3 +112,170 @@ def test_create_and_delete_share_by_map(page: Page, radicale_server: str) -> Non expect( page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") ).to_have_count(0) + + +def test_share_with_property_overrides(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + # Create a collection with specific details + page.click('a[data-name="new"]') + page.locator('#createcollectionscene input[data-name="displayname"]').fill( + "Test Collection" + ) + page.locator('#createcollectionscene input[data-name="description"]').fill( + "Original Description" + ) + page.locator('#createcollectionscene input[data-name="color"]').fill("#ff0000") + page.click('#createcollectionscene button[data-name="submit"]') + + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + page.click('button[data-name="sharebytoken"]') + + # Verify defaults + expect(page.locator('input[data-name="description_override"]')).to_have_value( + "Original Description" + ) + expect(page.locator('input[data-name="color_override"]')).to_have_value("#ff0000") + expect(page.locator('input[data-name="description_override"]')).to_be_disabled() + expect(page.locator('input[data-name="color_override"]')).to_be_disabled() + + # Set overrides + page.click('label[for="newshare_attr_description_enabled"]') + page.locator('input[data-name="description_override"]').fill( + "Overridden Description" + ) + page.click('label[for="newshare_attr_color_enabled"]') + page.locator('input[data-name="color_override"]').fill("#00ff00") + + page.click('#newshare button[data-name="submit"]') + + # Verify the share was created + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(1) + + +def test_share_journal_no_overrides(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + # Create a collection of type JOURNAL + page.click('a[data-name="new"]') + page.locator('#createcollectionscene select[data-name="type"]').select_option( + "JOURNAL" + ) + page.locator('#createcollectionscene input[data-name="displayname"]').fill( + "Test Journal" + ) + page.locator('#createcollectionscene input[data-name="description"]').fill( + "Journal Description" + ) + page.click('#createcollectionscene button[data-name="submit"]') + + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + page.click('button[data-name="sharebytoken"]') + + # Verify property override fieldset is hidden + expect(page.locator('fieldset[data-name="properties_override"]')).to_be_hidden() + + # Create the share + page.click('#newshare button[data-name="submit"]') + + # Verify the share was created + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden)") + ).to_have_count(1) + + +def test_edit_share_by_token(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + # Create RO share + page.click('button[data-name="sharebytoken"]') + page.click('#newshare button[data-name="submit"]') + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden) img[alt='RO']") + ).to_be_visible() + + # Edit to RW + page.click('tr:not(.hidden) button[data-name="edit"]') + expect(page.locator("#newshare h1")).to_have_text("Edit Share") + page.click('label[for="newshare_attr_permissions_rw"]') + page.click('#newshare button[data-name="submit"]') + + # Verify RW + expect( + page.locator("tr[data-name='sharetokenrowtemplate']:not(.hidden) img[alt='RW']") + ).to_be_visible() + + +def test_edit_share_by_map(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + # Create RO map share + page.click('button[data-name="sharebymap"]') + page.locator('input[data-name="shareuser"]').fill("max") + page.locator('input[data-name="sharehref"]').fill("mapped") + page.click('#newshare button[data-name="submit"]') + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden) img[alt='RO']") + ).to_be_visible() + + # Edit map share + page.click('tr:not(.hidden) button[data-name="edit"]') + expect(page.locator("#newshare h1")).to_have_text("Edit Share") + expect(page.locator('input[data-name="shareuser"]')).to_be_disabled() + expect(page.locator('input[data-name="sharehref"]')).to_be_disabled() + + # Change permissions and enabled status + page.click('label[for="newshare_attr_permissions_rw"]') + page.uncheck('input[data-name="enabled"]') + page.click('#newshare button[data-name="submit"]') + + # Verify changes + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden) img[alt='RW']") + ).to_be_visible() + # If disabled, it might not show up or show differently, but our current UI doesn't visually distinguish enabled/disabled in the list yet + # Let's verify by re-opening edit scene + page.click('tr:not(.hidden) button[data-name="edit"]') + expect(page.locator('input[data-name="enabled"]')).not_to_be_checked() + page.click('#newshare button[data-name="cancel"]') + + +def test_share_by_map_validation(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + create_collection(page, radicale_server) + page.hover("article:not(.hidden)") + page.click('article:not(.hidden) a[data-name="share"]', force=True, strict=True) + + page.click('button[data-name="sharebymap"]') + + # Try empty user + page.locator('input[data-name="shareuser"]').fill("") + page.locator('input[data-name="sharehref"]').fill("1234") + page.click('#newshare button[data-name="submit"]') + expect(page.locator('#newshare [data-name="error"]:not(.hidden)')).to_contain_text( + "Share User is empty" + ) + + # Try logged in user + page.locator('input[data-name="shareuser"]').fill("admin") + page.click('#newshare button[data-name="submit"]') + expect(page.locator('#newshare [data-name="error"]:not(.hidden)')).to_contain_text( + "Share User cannot be admin" + ) + + # Valid user + page.locator('input[data-name="shareuser"]').fill("max") + page.click('#newshare button[data-name="submit"]') + + # Verify success + expect( + page.locator("tr[data-name='sharemaprowtemplate']:not(.hidden)") + ).to_have_count(1) diff --git a/integ_tests/test_upload.py b/integ_tests/test_upload.py new file mode 100644 index 00000000..66a1ca7d --- /dev/null +++ b/integ_tests/test_upload.py @@ -0,0 +1,119 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2026-2026 Max Berger +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Integration tests for upload page +""" + +import pathlib +import re +from typing import Any, Generator + +import pytest +from playwright.sync_api import Page, expect + +from integ_tests.common import login, start_radicale_server + + +@pytest.fixture +def radicale_server(tmp_path: pathlib.Path) -> Generator[str, Any, None]: + yield from start_radicale_server(tmp_path) + + +def test_upload_zero_files(page: Page, radicale_server: str) -> None: + login(page, radicale_server) + page.click('.fabcontainer a[data-name="upload"]') + + # Click upload without selecting files + page.click('#uploadcollectionscene button[data-name="submit"]') + + # Check for error message at the bottom of the scene + error_locator = page.locator('#uploadcollectionscene > span[data-name="error"]') + expect(error_locator).to_be_visible() + expect(error_locator).to_contain_text("Please select at least one file") + + +def test_upload_one_file_custom_href( + page: Page, radicale_server: str, tmp_path: pathlib.Path +) -> None: + login(page, radicale_server) + + # Create a fake file to upload + test_file = tmp_path / "test.ics" + test_file.write_text("BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR") + + page.click('.fabcontainer a[data-name="upload"]') + + # Upload 1 file and set custom href + page.set_input_files( + '#uploadcollectionscene input[data-name="uploadfile"]', str(test_file) + ) + page.fill('#uploadcollectionscene input[data-name="href"]', "testcollection") + page.click('#uploadcollectionscene button[data-name="submit"]') + + # Wait for upload to complete in the list item + expect( + page.locator('#uploadcollectionscene li:not(.hidden) [data-name="success"]') + ).to_be_visible() + + # Close scene + page.click('#uploadcollectionscene button[data-name="close"]') + + # Verify 1 collection exists with "testcollection" in url + expect(page.locator("article:not(.hidden)")).to_have_count(1) + expect(page.locator('article:not(.hidden) input[data-name="url"]')).to_have_value( + re.compile(r".*testcollection/.*") + ) + + +def test_upload_two_files( + page: Page, radicale_server: str, tmp_path: pathlib.Path +) -> None: + login(page, radicale_server) + + # Create two fake files + file1 = tmp_path / "test1.ics" + file1.write_text("BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR") + file2 = tmp_path / "test2.ics" + file2.write_text("BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR") + + page.click('.fabcontainer a[data-name="upload"]') + + # Upload 2 files + page.set_input_files( + '#uploadcollectionscene input[data-name="uploadfile"]', [str(file1), str(file2)] + ) + + # HREF field should be hidden + expect( + page.locator('#uploadcollectionscene input[data-name="href"]') + ).to_be_hidden() + expect( + page.locator('#uploadcollectionscene [data-name="hreflimitmsg"]') + ).to_be_visible() + + page.click('#uploadcollectionscene button[data-name="submit"]') + + # Wait for uploads to complete + # Wait until 2 entries in the upload list show success + expect( + page.locator('#uploadcollectionscene li:not(.hidden) [data-name="success"]') + ).to_have_count(2) + + # Close scene + page.click('#uploadcollectionscene button[data-name="close"]') + # Verify 2 collections exist + expect(page.locator("article:not(.hidden)")).to_have_count(2) diff --git a/radicale/web/internal_data/css/main.css b/radicale/web/internal_data/css/main.css index 7ba2e92b..94ae598e 100644 --- a/radicale/web/internal_data/css/main.css +++ b/radicale/web/internal_data/css/main.css @@ -356,13 +356,13 @@ img.loading { .error::before { content: "!"; - height: 1em; + height: 1.4em; color: white; background: rgb(217, 48, 37); font-weight: bold; border-radius: 100%; display: inline-block; - width: 1.1em; + width: 1.4em; margin-right: 5px; font-size: 1em; text-align: center; diff --git a/radicale/web/internal_data/index.html b/radicale/web/internal_data/index.html index d77a9711..ca3a447a 100644 --- a/radicale/web/internal_data/index.html +++ b/radicale/web/internal_data/index.html @@ -153,17 +153,19 @@ - + RWRO + + + - - @@ -177,17 +179,19 @@ - + RWRO + + + - - +