From b024d4e8e439ea7cbce67bf39a511cff901db60b Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 6 Mar 2026 16:07:15 +0100 Subject: [PATCH 01/20] New tests Signed-off-by: dartcafe --- tests/Unit/Model/DateIntervalTest.php | 192 ++++++++++++++++++ tests/Unit/Model/DateTimeImmutableTest.php | 113 +++++++++++ tests/Unit/Model/DateTimeIntervalTextTest.php | 186 +++++++++++++++++ tests/Unit/Model/SequenceTest.php | 157 ++++++++++++++ tests/Unit/Model/SequenceUnitTest.php | 109 ++++++++++ 5 files changed, 757 insertions(+) create mode 100644 tests/Unit/Model/DateIntervalTest.php create mode 100644 tests/Unit/Model/DateTimeImmutableTest.php create mode 100644 tests/Unit/Model/DateTimeIntervalTextTest.php create mode 100644 tests/Unit/Model/SequenceTest.php create mode 100644 tests/Unit/Model/SequenceUnitTest.php diff --git a/tests/Unit/Model/DateIntervalTest.php b/tests/Unit/Model/DateIntervalTest.php new file mode 100644 index 0000000000..3dfb0c1890 --- /dev/null +++ b/tests/Unit/Model/DateIntervalTest.php @@ -0,0 +1,192 @@ +assertSame(1, $interval->d); + $this->assertSame(0, $interval->h); + $this->assertSame(0, $interval->s); + } + + public function testConstructorWithISO8601HourString(): void { + $interval = new DateInterval('PT1H'); + $this->assertSame(1, $interval->h); + $this->assertSame(0, $interval->d); + } + + public function testConstructorWithISO8601ComplexString(): void { + $interval = new DateInterval('P2DT3H'); + $this->assertSame(2, $interval->d); + $this->assertSame(3, $interval->h); + } + + // --- Constructor: int input (multiples of 86400 treated as days) --- + + public function testConstructorWithOneDayInSeconds(): void { + $interval = new DateInterval(86400); + $this->assertSame(1, $interval->d); + $this->assertSame(0, $interval->h); + $this->assertSame(0, $interval->i); + $this->assertSame(0, $interval->s); + } + + public function testConstructorWithThreeDaysInSeconds(): void { + $interval = new DateInterval(3 * 86400); + $this->assertSame(3, $interval->d); + } + + // --- Constructor: int input (not a multiple of 86400 treated as seconds) --- + + public function testConstructorWithSecondsNotDivisibleByDay(): void { + $interval = new DateInterval(3600); + $this->assertSame(3600, $interval->s); + $this->assertSame(0, $interval->d); + $this->assertSame(0, $interval->h); + } + + public function testConstructorWithSingleSecond(): void { + $interval = new DateInterval(1); + $this->assertSame(1, $interval->s); + } + + // --- Constructor: zero and null yield zero interval --- + + public function testConstructorWithZeroIntYieldsZeroInterval(): void { + $interval = new DateInterval(0); + $this->assertTrue($interval->isZeroDuration()); + } + + public function testConstructorWithNullYieldsZeroInterval(): void { + $interval = new DateInterval(null); + $this->assertTrue($interval->isZeroDuration()); + } + + // --- Constructor: copy from existing DateInterval --- + + public function testConstructorWithDateIntervalCopiesValues(): void { + $original = new DateInterval('P2DT3H'); + $copy = new DateInterval($original); + $this->assertSame($original->d, $copy->d); + $this->assertSame($original->h, $copy->h); + } + + // --- isZeroDuration --- + + public function testIsZeroDurationTrueForNullInput(): void { + $interval = new DateInterval(null); + $this->assertTrue($interval->isZeroDuration()); + } + + public function testIsZeroDurationTrueForZeroInt(): void { + $interval = new DateInterval(0); + $this->assertTrue($interval->isZeroDuration()); + } + + public function testIsZeroDurationFalseForOneDay(): void { + $interval = new DateInterval('P1D'); + $this->assertFalse($interval->isZeroDuration()); + } + + public function testIsZeroDurationFalseForOneHour(): void { + $interval = new DateInterval('PT1H'); + $this->assertFalse($interval->isZeroDuration()); + } + + // --- getISO --- + + public function testGetISOReturnsNullForZeroInterval(): void { + $interval = new DateInterval(null); + $this->assertNull($interval->getISO()); + } + + public function testGetISOReturnsStringForNonZeroInterval(): void { + $interval = new DateInterval('P1D'); + $iso = $interval->getISO(); + $this->assertNotNull($iso); + $this->assertStringStartsWith('P', $iso); + } + + public function testGetISOReturnsStringForTimeInterval(): void { + $interval = new DateInterval('PT2H'); + $this->assertNotNull($interval->getISO()); + } + + // --- isDayInterval --- + + public function testIsDayIntervalTrueForP1D(): void { + $interval = new DateInterval('P1D'); + $this->assertTrue($interval->isDayInterval()); + } + + public function testIsDayIntervalTrueForMultipleDays(): void { + $interval = new DateInterval('P5D'); + $this->assertTrue($interval->isDayInterval()); + } + + public function testIsDayIntervalTrueForIntOneDayInSeconds(): void { + $interval = new DateInterval(86400); + $this->assertTrue($interval->isDayInterval()); + } + + public function testIsDayIntervalFalseForHoursOnly(): void { + $interval = new DateInterval('PT1H'); + $this->assertFalse($interval->isDayInterval()); + } + + public function testIsDayIntervalFalseForMixedDayAndHour(): void { + $interval = new DateInterval('P1DT1H'); + $this->assertFalse($interval->isDayInterval()); + } + + public function testIsDayIntervalFalseForZeroInterval(): void { + $interval = new DateInterval(null); + $this->assertFalse($interval->isDayInterval()); + } + + // --- getSeconds --- + + public function testGetSecondsForOneDay(): void { + $interval = new DateInterval('P1D'); + $base = new DateTimeImmutable('2024-06-15T00:00:00+00:00'); + $this->assertSame(86400, $interval->getSeconds($base)); + } + + public function testGetSecondsForOneHour(): void { + $interval = new DateInterval('PT1H'); + $base = new DateTimeImmutable('2024-06-15T00:00:00+00:00'); + $this->assertSame(3600, $interval->getSeconds($base)); + } + + public function testGetSecondsForThreeDays(): void { + $interval = new DateInterval('P3D'); + $base = new DateTimeImmutable('2024-06-15T00:00:00+00:00'); + $this->assertSame(3 * 86400, $interval->getSeconds($base)); + } + + public function testGetSecondsForZeroInterval(): void { + $interval = new DateInterval(null); + $base = new DateTimeImmutable('2024-06-15T00:00:00+00:00'); + $this->assertSame(0, $interval->getSeconds($base)); + } + + public function testGetSecondsFromIntConstructorMatchesISO(): void { + $fromInt = new DateInterval(86400); + $fromISO = new DateInterval('P1D'); + $base = new DateTimeImmutable('2024-06-15T00:00:00+00:00'); + $this->assertSame($fromISO->getSeconds($base), $fromInt->getSeconds($base)); + } +} diff --git a/tests/Unit/Model/DateTimeImmutableTest.php b/tests/Unit/Model/DateTimeImmutableTest.php new file mode 100644 index 0000000000..14cf9dfae4 --- /dev/null +++ b/tests/Unit/Model/DateTimeImmutableTest.php @@ -0,0 +1,113 @@ +assertSame(0, $dt->getTimestamp()); + } + + public function testConstructorWithZeroIntYieldsEpoch(): void { + $dt = new DateTimeImmutable(0); + $this->assertSame(0, $dt->getTimestamp()); + } + + public function testConstructorWithNegativeIntYieldsEpoch(): void { + $dt = new DateTimeImmutable(-1); + $this->assertSame(0, $dt->getTimestamp()); + } + + public function testConstructorWithEmptyStringYieldsEpoch(): void { + $dt = new DateTimeImmutable(''); + $this->assertSame(0, $dt->getTimestamp()); + } + + public function testConstructorWithPositiveIntUsesUnixTimestamp(): void { + $timestamp = 1700000000; + $dt = new DateTimeImmutable($timestamp); + $this->assertSame($timestamp, $dt->getTimestamp()); + } + + public function testConstructorWithISOStringParsesCorrectly(): void { + $dt = new DateTimeImmutable('2024-03-15T14:30:00+00:00'); + $this->assertSame('2024-03-15', $dt->format('Y-m-d')); + $this->assertSame('14:30:00', $dt->format('H:i:s')); + } + + public function testConstructorWithDateTimeImmutableCopiesTimestamp(): void { + $original = new DateTimeImmutable('2024-06-01T08:00:00+00:00'); + $copy = new DateTimeImmutable($original); + $this->assertSame($original->getTimestamp(), $copy->getTimestamp()); + } + + // --- isStartOfDay --- + + public function testIsStartOfDayTrueAtMidnight(): void { + $dt = new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + $this->assertTrue($dt->isStartOfDay()); + } + + public function testIsStartOfDayFalseAtNoon(): void { + $dt = new DateTimeImmutable('2024-01-01T12:00:00+00:00'); + $this->assertFalse($dt->isStartOfDay()); + } + + public function testIsStartOfDayFalseOneSecondPastMidnight(): void { + $dt = new DateTimeImmutable('2024-01-01T00:00:01+00:00'); + $this->assertFalse($dt->isStartOfDay()); + } + + // --- getISO --- + + public function testGetISOReturnsNullForEpoch(): void { + $dt = new DateTimeImmutable(null); + $this->assertNull($dt->getISO()); + } + + public function testGetISOReturnsNullForZeroTimestamp(): void { + $dt = new DateTimeImmutable(0); + $this->assertNull($dt->getISO()); + } + + public function testGetISOReturnsStringForValidDate(): void { + $dt = new DateTimeImmutable('2024-03-15T14:30:00+00:00'); + $iso = $dt->getISO(); + $this->assertNotNull($iso); + $this->assertStringContainsString('2024-03-15', $iso); + $this->assertStringContainsString('14:30:00', $iso); + } + + public function testGetISOReturnsStringForPositiveTimestamp(): void { + $dt = new DateTimeImmutable(1700000000); + $this->assertNotNull($dt->getISO()); + } + + // --- getISODate --- + + public function testGetISODateReturnsNullForEpoch(): void { + $dt = new DateTimeImmutable(null); + $this->assertNull($dt->getISODate()); + } + + public function testGetISODateReturnsDateStringForValidDate(): void { + $dt = new DateTimeImmutable('2024-03-15T14:30:00+00:00'); + $this->assertSame('2024-03-15', $dt->getISODate()); + } + + public function testGetISODateReturnsOnlyDatePortion(): void { + $dt = new DateTimeImmutable('2024-12-31T23:59:59+00:00'); + $this->assertSame('2024-12-31', $dt->getISODate()); + } +} diff --git a/tests/Unit/Model/DateTimeIntervalTextTest.php b/tests/Unit/Model/DateTimeIntervalTextTest.php new file mode 100644 index 0000000000..65fd9244df --- /dev/null +++ b/tests/Unit/Model/DateTimeIntervalTextTest.php @@ -0,0 +1,186 @@ +l10n = $this->createMock(IL10N::class); + $this->l10n->method('l') + ->willReturnCallback(static function (string $type, $data): string { + if (!$data instanceof \DateTime) { + return ''; + } + return match ($type) { + 'date' => $data->format('Y-m-d'), + 'time' => $data->format('H:i'), + default => $data->format('Y-m-d H:i'), + }; + }); + } + + private function makeSubject(): DateTimeIntervalText { + return new DateTimeIntervalText($this->l10n); + } + + // --- Getters --- + + public function testGetDateTimeReturnsSetStart(): void { + $subject = $this->makeSubject(); + $start = new DateTimeImmutable('2024-05-10T10:00:00+00:00'); + $subject->setStartDateTime($start); + $this->assertSame($start->getTimestamp(), $subject->getDateTime()->getTimestamp()); + } + + public function testGetIntervalReturnsSetInterval(): void { + $subject = $this->makeSubject(); + $interval = new DateInterval('PT2H'); + $subject->setInterval($interval); + $this->assertSame($interval->h, $subject->getInterval()->h); + } + + public function testGetEndDateTimeAddsIntervalToStart(): void { + $subject = $this->makeSubject(); + $start = new DateTimeImmutable('2024-05-10T10:00:00+00:00'); + $interval = new DateInterval('PT2H'); + $subject->setStartDateTime($start); + $subject->setInterval($interval); + $end = $subject->getEndDateTime(); + $this->assertSame($start->getTimestamp() + 7200, $end->getTimestamp()); + } + + // --- getLocalizedDateTimeString: branch 1 --- + // Zero duration, NOT start of day → format='datetime' + + public function testZeroDurationNotStartOfDayReturnsSingleDatetime(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T10:30:00+00:00')); + $subject->setInterval(new DateInterval(null)); + + $result = $subject->getLocalizedDateTimeString(); + + $this->assertSame('2024-01-15 10:30', $result); + } + + // --- getLocalizedDateTimeString: branch 2 --- + // Zero duration, IS start of day → format='date' + + public function testZeroDurationStartOfDayReturnsSingleDate(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T00:00:00+00:00')); + $subject->setInterval(new DateInterval(null)); + + $result = $subject->getLocalizedDateTimeString(); + + $this->assertSame('2024-01-15', $result); + } + + // --- getLocalizedDateTimeString: branch 3 --- + // Day interval, IS start of day, same day after subtracting P1D → single date + + public function testSingleDayFullDayEventReturnsSingleDate(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T00:00:00+00:00')); + $subject->setInterval(new DateInterval('P1D')); + + $result = $subject->getLocalizedDateTimeString(); + + $this->assertSame('2024-01-15', $result); + } + + // --- getLocalizedDateTimeString: branch 4 --- + // Day interval, IS start of day, spanning multiple days → "start – end" + + public function testMultiDayFullDayEventReturnsDateRange(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T00:00:00+00:00')); + $subject->setInterval(new DateInterval('P3D')); + + $result = $subject->getLocalizedDateTimeString(); + + // end = 2024-01-18, minus P1D = 2024-01-17 (inclusive end) + $this->assertSame('2024-01-15 – 2024-01-17', $result); + } + + public function testTwoDayFullDayEventReturnsDateRange(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T00:00:00+00:00')); + $subject->setInterval(new DateInterval('P2D')); + + $result = $subject->getLocalizedDateTimeString(); + + // end = 2024-01-17, minus P1D = 2024-01-16 + $this->assertSame('2024-01-15 – 2024-01-16', $result); + } + + // --- getLocalizedDateTimeString: branch 5 --- + // Time range on the same calendar day → "datetime – time" + + public function testSameDayTimeRangeReturnsDatetimeThenTimeOnly(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T10:00:00+00:00')); + $subject->setInterval(new DateInterval('PT2H')); + + $result = $subject->getLocalizedDateTimeString(); + + $this->assertSame('2024-01-15 10:00 – 12:00', $result); + } + + public function testSameDayTimeRangeEndingAtMidnightIsNotSameDay(): void { + $subject = $this->makeSubject(); + // 22:00 + 2h = 00:00 next day → cross-day + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T22:00:00+00:00')); + $subject->setInterval(new DateInterval('PT2H')); + + $result = $subject->getLocalizedDateTimeString(); + + $this->assertSame('2024-01-15 22:00 – 2024-01-16 00:00', $result); + } + + // --- getLocalizedDateTimeString: branch 6 --- + // Time range spanning midnight → "datetime – datetime" + + public function testCrossDayTimeRangeReturnsTwoDatetimes(): void { + $subject = $this->makeSubject(); + $subject->setStartDateTime(new DateTimeImmutable('2024-01-15T22:00:00+00:00')); + $subject->setInterval(new DateInterval('PT4H')); + + $result = $subject->getLocalizedDateTimeString(); + + // start: 2024-01-15 22:00, end: 2024-01-16 02:00 + $this->assertSame('2024-01-15 22:00 – 2024-01-16 02:00', $result); + } + + // --- setStartDateTime / setInterval return $this for chaining --- + + public function testFluentInterfaceForSetStartDateTime(): void { + $subject = $this->makeSubject(); + $returned = $subject->setStartDateTime(new DateTimeImmutable('2024-01-01T00:00:00+00:00')); + $this->assertSame($subject, $returned); + } + + public function testFluentInterfaceForSetInterval(): void { + $subject = $this->makeSubject(); + $returned = $subject->setInterval(new DateInterval('P1D')); + $this->assertSame($subject, $returned); + } +} diff --git a/tests/Unit/Model/SequenceTest.php b/tests/Unit/Model/SequenceTest.php new file mode 100644 index 0000000000..06d53bdeab --- /dev/null +++ b/tests/Unit/Model/SequenceTest.php @@ -0,0 +1,157 @@ +assertSame($unit, $seq->getUnit()); + } + + public function testGetStepWidthReturnsConstructedValue(): void { + $seq = new Sequence(new SequenceUnit(SequenceUnit::REPETITION_DAY), 3, 7); + $this->assertSame(3, $seq->getStepWidth()); + } + + public function testGetRepetitionsReturnsConstructedValue(): void { + $seq = new Sequence(new SequenceUnit(SequenceUnit::REPETITION_DAY), 1, 10); + $this->assertSame(10, $seq->getRepetitions()); + } + + // --- jsonSerialize --- + + public function testJsonSerializeContainsRequiredKeys(): void { + $seq = new Sequence(new SequenceUnit(SequenceUnit::REPETITION_WEEK), 1, 4); + $json = $seq->jsonSerialize(); + + $this->assertArrayHasKey('unit', $json); + $this->assertArrayHasKey('stepWidth', $json); + $this->assertArrayHasKey('repetitions', $json); + } + + public function testJsonSerializeValuesMatchConstructorInput(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_MONTH); + $seq = new Sequence($unit, 2, 6); + $json = $seq->jsonSerialize(); + + $this->assertSame(2, $json['stepWidth']); + $this->assertSame(6, $json['repetitions']); + } + + public function testJsonSerializeUnitIsSerialized(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_WEEK); + $seq = new Sequence($unit, 1, 3); + $json = $seq->jsonSerialize(); + + // The 'unit' value is the SequenceUnit itself (JsonSerializable), so it must be an array + $this->assertIsArray($json['unit']); + $this->assertSame('week', $json['unit']['id']); + } + + // --- fromArray --- + + public function testFromArrayWithNullReturnsDefaultSequence(): void { + $seq = Sequence::fromArray(null); + $this->assertSame(SequenceUnit::REPETITION_DAY, $seq->getUnit()->getId()); + $this->assertSame(1, $seq->getStepWidth()); + $this->assertSame(0, $seq->getRepetitions()); + } + + public function testFromArrayWithValidDataReturnsCorrectSequence(): void { + $data = [ + 'unit' => ['id' => SequenceUnit::REPETITION_WEEK], + 'stepWidth' => 2, + 'repetitions' => 4, + ]; + $seq = Sequence::fromArray($data); + + $this->assertSame(SequenceUnit::REPETITION_WEEK, $seq->getUnit()->getId()); + $this->assertSame(2, $seq->getStepWidth()); + $this->assertSame(4, $seq->getRepetitions()); + } + + public function testFromArrayFallsBackToDefaultUnitWhenMissing(): void { + $seq = Sequence::fromArray(['stepWidth' => 3, 'repetitions' => 5]); + $this->assertSame(SequenceUnit::REPETITION_DAY, $seq->getUnit()->getId()); + $this->assertSame(3, $seq->getStepWidth()); + $this->assertSame(5, $seq->getRepetitions()); + } + + public function testFromArrayFallsBackToDefaultStepWidthWhenMissing(): void { + $seq = Sequence::fromArray(['unit' => ['id' => 'month'], 'repetitions' => 2]); + $this->assertSame(1, $seq->getStepWidth()); + } + + public function testFromArrayFallsBackToDefaultRepetitionsWhenMissing(): void { + $seq = Sequence::fromArray(['unit' => ['id' => 'day'], 'stepWidth' => 1]); + $this->assertSame(0, $seq->getRepetitions()); + } + + // --- getOccurence --- + + public function testGetOccurenceAtIndexZeroReturnsSameDate(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_DAY); + $seq = new Sequence($unit, 1, 7); + $base = new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + $seq->setBaseDateTime($base); + + $occurrence = $seq->getOccurence(0); + $this->assertSame('2024-01-01', $occurrence->getISODate()); + } + + public function testGetOccurenceAtIndexOneReturnsPlusOneStep(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_DAY); + $seq = new Sequence($unit, 1, 7); + $base = new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + $seq->setBaseDateTime($base); + + $occurrence = $seq->getOccurence(1); + $this->assertSame('2024-01-02', $occurrence->getISODate()); + } + + public function testGetOccurenceWithStepWidthThree(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_DAY); + $seq = new Sequence($unit, 3, 7); + $base = new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + $seq->setBaseDateTime($base); + + // index=2: 2 * 3 = 6 days later + $occurrence = $seq->getOccurence(2); + $this->assertSame('2024-01-07', $occurrence->getISODate()); + } + + public function testGetOccurenceReturnsDateTimeImmutableInstance(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_DAY); + $seq = new Sequence($unit, 1, 3); + $base = new DateTimeImmutable('2024-06-01T00:00:00+00:00'); + $seq->setBaseDateTime($base); + + $occurrence = $seq->getOccurence(1); + $this->assertInstanceOf(DateTimeImmutable::class, $occurrence); + } + + public function testGetOccurenceWithMonthUnit(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_MONTH); + $seq = new Sequence($unit, 1, 12); + $base = new DateTimeImmutable('2024-01-01T00:00:00+00:00'); + $seq->setBaseDateTime($base); + + // index=3: 3 months later + $occurrence = $seq->getOccurence(3); + $this->assertSame('2024-04-01', $occurrence->getISODate()); + } +} diff --git a/tests/Unit/Model/SequenceUnitTest.php b/tests/Unit/Model/SequenceUnitTest.php new file mode 100644 index 0000000000..107d4e794d --- /dev/null +++ b/tests/Unit/Model/SequenceUnitTest.php @@ -0,0 +1,109 @@ +assertSame(SequenceUnit::REPETITION_DAY, $unit->getId()); + } + + public function testGetIdReturnsWeekConstant(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_WEEK); + $this->assertSame(SequenceUnit::REPETITION_WEEK, $unit->getId()); + } + + // --- jsonSerialize: structure --- + + /** + * @dataProvider unitProvider + */ + public function testJsonSerializeContainsRequiredKeys(string $unitId): void { + $unit = new SequenceUnit($unitId); + $json = $unit->jsonSerialize(); + + $this->assertArrayHasKey('id', $json); + $this->assertArrayHasKey('luxonUnit', $json); + $this->assertArrayHasKey('name', $json); + } + + /** + * @dataProvider unitProvider + */ + public function testJsonSerializeIdMatchesConstructorInput(string $unitId): void { + $unit = new SequenceUnit($unitId); + $this->assertSame($unitId, $unit->jsonSerialize()['id']); + } + + // --- jsonSerialize: concrete expected values --- + + public function testJsonSerializeForYear(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_YEAR); + $json = $unit->jsonSerialize(); + $this->assertSame('year', $json['id']); + $this->assertSame('years', $json['luxonUnit']); + $this->assertSame('Year', $json['name']); + } + + public function testJsonSerializeForMonth(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_MONTH); + $json = $unit->jsonSerialize(); + $this->assertSame('month', $json['id']); + $this->assertSame('months', $json['luxonUnit']); + $this->assertSame('Month', $json['name']); + } + + public function testJsonSerializeForWeek(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_WEEK); + $json = $unit->jsonSerialize(); + $this->assertSame('week', $json['id']); + $this->assertSame('weeks', $json['luxonUnit']); + $this->assertSame('Week', $json['name']); + } + + public function testJsonSerializeForDay(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_DAY); + $json = $unit->jsonSerialize(); + $this->assertSame('day', $json['id']); + $this->assertSame('days', $json['luxonUnit']); + $this->assertSame('Day', $json['name']); + } + + public function testJsonSerializeForHour(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_HOUR); + $json = $unit->jsonSerialize(); + $this->assertSame('hour', $json['id']); + $this->assertSame('hours', $json['luxonUnit']); + $this->assertSame('Hour', $json['name']); + } + + public function testJsonSerializeForMinute(): void { + $unit = new SequenceUnit(SequenceUnit::REPETITION_MINUTE); + $json = $unit->jsonSerialize(); + $this->assertSame('minute', $json['id']); + $this->assertSame('minutes', $json['luxonUnit']); + $this->assertSame('Minute', $json['name']); + } + + public static function unitProvider(): array { + return [ + 'year' => [SequenceUnit::REPETITION_YEAR], + 'month' => [SequenceUnit::REPETITION_MONTH], + 'week' => [SequenceUnit::REPETITION_WEEK], + 'day' => [SequenceUnit::REPETITION_DAY], + 'hour' => [SequenceUnit::REPETITION_HOUR], + 'minute' => [SequenceUnit::REPETITION_MINUTE], + ]; + } +} From da6c95b298de1982330e105d9d48e134be3bd040 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 6 Mar 2026 16:13:16 +0100 Subject: [PATCH 02/20] fix Signed-off-by: dartcafe --- tests/Unit/Model/SequenceTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Unit/Model/SequenceTest.php b/tests/Unit/Model/SequenceTest.php index 06d53bdeab..5cbf25178a 100644 --- a/tests/Unit/Model/SequenceTest.php +++ b/tests/Unit/Model/SequenceTest.php @@ -55,9 +55,8 @@ public function testJsonSerializeValuesMatchConstructorInput(): void { public function testJsonSerializeUnitIsSerialized(): void { $unit = new SequenceUnit(SequenceUnit::REPETITION_WEEK); $seq = new Sequence($unit, 1, 3); - $json = $seq->jsonSerialize(); + $json = json_decode((string)json_encode($seq), true); - // The 'unit' value is the SequenceUnit itself (JsonSerializable), so it must be an array $this->assertIsArray($json['unit']); $this->assertSame('week', $json['unit']['id']); } From 4a6d34d481abebb231111512283b091abf82b69c Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 6 Mar 2026 16:22:12 +0100 Subject: [PATCH 03/20] DiffService Signed-off-by: dartcafe --- tests/Unit/Service/DiffServiceTest.php | 227 +++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 tests/Unit/Service/DiffServiceTest.php diff --git a/tests/Unit/Service/DiffServiceTest.php b/tests/Unit/Service/DiffServiceTest.php new file mode 100644 index 0000000000..3f8eb57f80 --- /dev/null +++ b/tests/Unit/Service/DiffServiceTest.php @@ -0,0 +1,227 @@ +createMock(Poll::class); + $poll->method('jsonSerialize')->willReturn($data); + return $poll; + } + + // --- Initial state before setComparisonObject --- + + public function testFullDiffIsEmptyBeforeComparison(): void { + $service = new DiffService($this->makePoll(['title' => 'Test'])); + $this->assertSame([], $service->getFullDiff()); + } + + public function testNewValuesDiffIsEmptyBeforeComparison(): void { + $service = new DiffService($this->makePoll(['title' => 'Test'])); + $this->assertSame([], $service->getNewValuesDiff()); + } + + // --- Identical objects produce empty diffs --- + + public function testFullDiffIsEmptyForIdenticalObjects(): void { + $data = ['title' => 'My Poll', 'type' => 'datePoll', 'expire' => 0]; + $service = new DiffService($this->makePoll($data)); + $service->setComparisonObject($this->makePoll($data)); + + $this->assertSame([], $service->getFullDiff()); + } + + public function testNewValuesDiffIsEmptyForIdenticalObjects(): void { + $data = ['title' => 'My Poll', 'type' => 'textPoll']; + $service = new DiffService($this->makePoll($data)); + $service->setComparisonObject($this->makePoll($data)); + + $this->assertSame([], $service->getNewValuesDiff()); + } + + // --- Single scalar field changed --- + + public function testFullDiffDetectsSingleFieldChange(): void { + $service = new DiffService($this->makePoll(['title' => 'Original'])); + $service->setComparisonObject($this->makePoll(['title' => 'Updated'])); + + $diff = $service->getFullDiff(); + + $this->assertArrayHasKey('title', $diff); + $this->assertSame('Original', $diff['title']['old']); + $this->assertSame('Updated', $diff['title']['new']); + } + + public function testNewValuesDiffReturnsSingleChangedField(): void { + $service = new DiffService($this->makePoll(['title' => 'Original'])); + $service->setComparisonObject($this->makePoll(['title' => 'Updated'])); + + $this->assertSame(['title' => 'Updated'], $service->getNewValuesDiff()); + } + + // --- Multiple fields changed --- + + public function testFullDiffDetectsMultipleChangedFields(): void { + $service = new DiffService($this->makePoll([ + 'title' => 'Old Title', + 'type' => 'datePoll', + 'expire' => 0, + ])); + $service->setComparisonObject($this->makePoll([ + 'title' => 'New Title', + 'type' => 'textPoll', + 'expire' => 0, + ])); + + $diff = $service->getFullDiff(); + + $this->assertArrayHasKey('title', $diff); + $this->assertArrayHasKey('type', $diff); + $this->assertArrayNotHasKey('expire', $diff); + $this->assertSame('New Title', $diff['title']['new']); + $this->assertSame('textPoll', $diff['type']['new']); + } + + public function testNewValuesDiffReturnsOnlyChangedFields(): void { + $service = new DiffService($this->makePoll([ + 'title' => 'Old Title', + 'type' => 'datePoll', + 'expire' => 0, + ])); + $service->setComparisonObject($this->makePoll([ + 'title' => 'New Title', + 'type' => 'textPoll', + 'expire' => 0, + ])); + + $newValues = $service->getNewValuesDiff(); + + $this->assertArrayHasKey('title', $newValues); + $this->assertArrayHasKey('type', $newValues); + $this->assertArrayNotHasKey('expire', $newValues); + $this->assertSame('New Title', $newValues['title']); + $this->assertSame('textPoll', $newValues['type']); + } + + // --- Key added in comparison (not in base) --- + + public function testFullDiffDetectsNewKeyInComparison(): void { + $service = new DiffService($this->makePoll(['title' => 'Test'])); + $service->setComparisonObject($this->makePoll(['title' => 'Test', 'extra' => 'added'])); + + $diff = $service->getFullDiff(); + + $this->assertArrayHasKey('extra', $diff); + $this->assertNull($diff['extra']['old']); + $this->assertSame('added', $diff['extra']['new']); + } + + // --- Key removed in comparison (present in base, absent in comparison) --- + + public function testFullDiffDetectsRemovedKeyInComparison(): void { + $service = new DiffService($this->makePoll(['title' => 'Test', 'extra' => 'was here'])); + $service->setComparisonObject($this->makePoll(['title' => 'Test'])); + + $diff = $service->getFullDiff(); + + $this->assertArrayHasKey('extra', $diff); + $this->assertSame('was here', $diff['extra']['old']); + $this->assertNull($diff['extra']['new']); + } + + public function testNewValuesDiffForRemovedKeyContainsNull(): void { + $service = new DiffService($this->makePoll(['title' => 'Test', 'extra' => 'was here'])); + $service->setComparisonObject($this->makePoll(['title' => 'Test'])); + + $newValues = $service->getNewValuesDiff(); + + $this->assertArrayHasKey('extra', $newValues); + $this->assertNull($newValues['extra']); + } + + // --- Nested array changes --- + + public function testFullDiffDetectsNestedFieldChange(): void { + $service = new DiffService($this->makePoll([ + 'configuration' => ['access' => 'private', 'allowComment' => false], + ])); + $service->setComparisonObject($this->makePoll([ + 'configuration' => ['access' => 'open', 'allowComment' => false], + ])); + + $diff = $service->getFullDiff(); + + $this->assertArrayHasKey('configuration', $diff); + $this->assertArrayHasKey('access', $diff['configuration']); + $this->assertSame('private', $diff['configuration']['access']['old']); + $this->assertSame('open', $diff['configuration']['access']['new']); + $this->assertArrayNotHasKey('allowComment', $diff['configuration']); + } + + public function testNewValuesDiffPreservesNestedStructure(): void { + $service = new DiffService($this->makePoll([ + 'configuration' => ['access' => 'private', 'expire' => 0], + ])); + $service->setComparisonObject($this->makePoll([ + 'configuration' => ['access' => 'open', 'expire' => 0], + ])); + + $newValues = $service->getNewValuesDiff(); + + $this->assertArrayHasKey('configuration', $newValues); + $this->assertArrayHasKey('access', $newValues['configuration']); + $this->assertSame('open', $newValues['configuration']['access']); + $this->assertArrayNotHasKey('expire', $newValues['configuration']); + } + + // --- Recalculation when setComparisonObject is called again --- + + public function testDiffIsRecalculatedOnSubsequentSetComparisonObjectCall(): void { + $service = new DiffService($this->makePoll(['title' => 'Base'])); + + $service->setComparisonObject($this->makePoll(['title' => 'First'])); + $this->assertSame('First', $service->getFullDiff()['title']['new']); + + $service->setComparisonObject($this->makePoll(['title' => 'Second'])); + $this->assertSame('Second', $service->getFullDiff()['title']['new']); + } + + // --- Type changes --- + + public function testFullDiffDetectsTypeChange(): void { + $service = new DiffService($this->makePoll(['expire' => 0])); + $service->setComparisonObject($this->makePoll(['expire' => 1700000000])); + + $diff = $service->getFullDiff(); + + $this->assertSame(0, $diff['expire']['old']); + $this->assertSame(1700000000, $diff['expire']['new']); + } + + public function testFullDiffDetectsBooleanChange(): void { + $service = new DiffService($this->makePoll(['allowComment' => false])); + $service->setComparisonObject($this->makePoll(['allowComment' => true])); + + $diff = $service->getFullDiff(); + + $this->assertSame(false, $diff['allowComment']['old']); + $this->assertSame(true, $diff['allowComment']['new']); + } +} From f5a8ad8249863f9c7e7e0a3803a23a19a561a363 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 6 Mar 2026 19:50:18 +0100 Subject: [PATCH 04/20] use terser for minification Signed-off-by: dartcafe --- package-lock.json | 293 +++++++++++++++++++++++----------------------- package.json | 1 + vite.config.js | 15 ++- 3 files changed, 158 insertions(+), 151 deletions(-) diff --git a/package-lock.json b/package-lock.json index 55be4a0185..4760f27c83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "eslint-config-prettier": "^10.1.5", "eslint-plugin-prettier": "^5.5.5", "prettier": "^3.8.1", + "terser": "^5.46.0", "vite": "^7.3.1", "vite-plugin-node-polyfills": "^0.25.0", "xml2js": "^0.6.2" @@ -1240,6 +1241,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2469,9 +2481,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.0.tgz", + "integrity": "sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ==", "cpu": [ "arm" ], @@ -2483,9 +2495,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.0.tgz", + "integrity": "sha512-2O73dR4Dc9bp+wSYhviP6sDziurB5/HCym7xILKifWdE9UsOe2FtNcM+I4xZjKrfLJnq5UR8k9riB87gauiQtw==", "cpu": [ "arm64" ], @@ -2497,9 +2509,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.0.tgz", + "integrity": "sha512-vwSXQN8T4sKf1RHr1F0s98Pf8UPz7pS6P3LG9NSmuw0TVh7EmaE+5Ny7hJOZ0M2yuTctEsHHRTMi2wuHkdS6Hg==", "cpu": [ "arm64" ], @@ -2511,9 +2523,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.0.tgz", + "integrity": "sha512-cQp/WG8HE7BCGyFVuzUg0FNmupxC+EPZEwWu2FCGGw5WDT1o2/YlENbm5e9SMvfDFR6FRhVCBePLqj0o8MN7Vw==", "cpu": [ "x64" ], @@ -2525,9 +2537,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.0.tgz", + "integrity": "sha512-UR1uTJFU/p801DvvBbtDD7z9mQL8J80xB0bR7DqW7UGQHRm/OaKzp4is7sQSdbt2pjjSS72eAtRh43hNduTnnQ==", "cpu": [ "arm64" ], @@ -2539,9 +2551,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.0.tgz", + "integrity": "sha512-G/DKyS6PK0dD0+VEzH/6n/hWDNPDZSMBmqsElWnCRGrYOb2jC0VSupp7UAHHQ4+QILwkxSMaYIbQ72dktp8pKA==", "cpu": [ "x64" ], @@ -2553,9 +2565,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.0.tgz", + "integrity": "sha512-u72Mzc6jyJwKjJbZZcIYmd9bumJu7KNmHYdue43vT1rXPm2rITwmPWF0mmPzLm9/vJWxIRbao/jrQmxTO0Sm9w==", "cpu": [ "arm" ], @@ -2567,9 +2579,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.0.tgz", + "integrity": "sha512-S4UefYdV0tnynDJV1mdkNawp0E5Qm2MtSs330IyHgaccOFrwqsvgigUD29uT+B/70PDY1eQ3t40+xf6wIvXJyg==", "cpu": [ "arm" ], @@ -2581,9 +2593,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.0.tgz", + "integrity": "sha512-1EhkSvUQXJsIhk4msxP5nNAUWoB4MFDHhtc4gAYvnqoHlaL9V3F37pNHabndawsfy/Tp7BPiy/aSa6XBYbaD1g==", "cpu": [ "arm64" ], @@ -2595,9 +2607,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.0.tgz", + "integrity": "sha512-EtBDIZuDtVg75xIPIK1l5vCXNNCIRM0OBPUG+tbApDuJAy9mKago6QxX+tfMzbCI6tXEhMuZuN1+CU8iDW+0UQ==", "cpu": [ "arm64" ], @@ -2608,24 +2620,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.0.tgz", + "integrity": "sha512-BGYSwJdMP0hT5CCmljuSNx7+k+0upweM2M4YGfFBjnFSZMHOLYR0gEEj/dxyYJ6Zc6AiSeaBY8dWOa11GF/ppQ==", "cpu": [ "loong64" ], @@ -2637,23 +2635,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.0.tgz", + "integrity": "sha512-I1gSMzkVe1KzAxKAroCJL30hA4DqSi+wGc5gviD0y3IL/VkvcnAqwBf4RHXHyvH66YVHxpKO8ojrgc4SrWAnLg==", "cpu": [ "ppc64" ], @@ -2665,9 +2649,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.0.tgz", + "integrity": "sha512-bSbWlY3jZo7molh4tc5dKfeSxkqnf48UsLqYbUhnkdnfgZjgufLS/NTA8PcP/dnvct5CCdNkABJ56CbclMRYCA==", "cpu": [ "riscv64" ], @@ -2679,9 +2663,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.0.tgz", + "integrity": "sha512-LSXSGumSURzEQLT2e4sFqFOv3LWZsEF8FK7AAv9zHZNDdMnUPYH3t8ZlaeYYZyTXnsob3htwTKeWtBIkPV27iQ==", "cpu": [ "riscv64" ], @@ -2693,9 +2677,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.0.tgz", + "integrity": "sha512-CxRKyakfDrsLXiCyucVfVWVoaPA4oFSpPpDwlMcDFQvrv3XY6KEzMtMZrA+e/goC8xxp2WSOxHQubP8fPmmjOQ==", "cpu": [ "s390x" ], @@ -2707,9 +2691,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.0.tgz", + "integrity": "sha512-8PrJJA7/VU8ToHVEPu14FzuSAqVKyo5gg/J8xUerMbyNkWkO9j2ExBho/68RnJsMGNJq4zH114iAttgm7BZVkA==", "cpu": [ "x64" ], @@ -2721,9 +2705,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.0.tgz", + "integrity": "sha512-SkE6YQp+CzpyOrbw7Oc4MgXFvTw2UIBElvAvLCo230pyxOLmYwRPwZ/L5lBe/VW/qT1ZgND9wJfOsdy0XptRvw==", "cpu": [ "x64" ], @@ -2734,24 +2718,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.0.tgz", + "integrity": "sha512-PZkNLPfvXeIOgJWA804zjSFH7fARBBCpCXxgkGDRjjAhRLOR8o0IGS01ykh5GYfod4c2yiiREuDM8iZ+pVsT+Q==", "cpu": [ "arm64" ], @@ -2763,9 +2733,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.0.tgz", + "integrity": "sha512-q7cIIdFvWQoaCbLDUyUc8YfR3Jh2xx3unO8Dn6/TTogKjfwrax9SyfmGGK6cQhKtjePI7jRfd7iRYcxYs93esg==", "cpu": [ "arm64" ], @@ -2777,9 +2747,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.0.tgz", + "integrity": "sha512-XzNOVg/YnDOmFdDKcxxK410PrcbcqZkBmz+0FicpW5jtjKQxcW1BZJEQOF0NJa6JO7CZhett8GEtRN/wYLYJuw==", "cpu": [ "ia32" ], @@ -2790,24 +2760,10 @@ "win32" ] }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.0.tgz", + "integrity": "sha512-xMmiWRR8sp72Zqwjgtf3QbZfF1wdh8X2ABu3EaozvZcyHJeU0r+XAnXdKgs4cCAp6ORoYoCygipYP1mjmbjrsg==", "cpu": [ "x64" ], @@ -4793,6 +4749,13 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", @@ -5138,6 +5101,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/comment-parser": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", @@ -11399,9 +11369,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.50.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.0.tgz", + "integrity": "sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw==", "dev": true, "license": "MIT", "peer": true, @@ -11416,31 +11386,27 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.50.0", + "@rollup/rollup-android-arm64": "4.50.0", + "@rollup/rollup-darwin-arm64": "4.50.0", + "@rollup/rollup-darwin-x64": "4.50.0", + "@rollup/rollup-freebsd-arm64": "4.50.0", + "@rollup/rollup-freebsd-x64": "4.50.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.0", + "@rollup/rollup-linux-arm-musleabihf": "4.50.0", + "@rollup/rollup-linux-arm64-gnu": "4.50.0", + "@rollup/rollup-linux-arm64-musl": "4.50.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.50.0", + "@rollup/rollup-linux-ppc64-gnu": "4.50.0", + "@rollup/rollup-linux-riscv64-gnu": "4.50.0", + "@rollup/rollup-linux-riscv64-musl": "4.50.0", + "@rollup/rollup-linux-s390x-gnu": "4.50.0", + "@rollup/rollup-linux-x64-gnu": "4.50.0", + "@rollup/rollup-linux-x64-musl": "4.50.0", + "@rollup/rollup-openharmony-arm64": "4.50.0", + "@rollup/rollup-win32-arm64-msvc": "4.50.0", + "@rollup/rollup-win32-ia32-msvc": "4.50.0", + "@rollup/rollup-win32-x64-msvc": "4.50.0", "fsevents": "~2.3.2" } }, @@ -11950,6 +11916,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -12956,6 +12933,26 @@ "dev": true, "license": "MIT" }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index 63a733d025..4bd858cd40 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "eslint-config-prettier": "^10.1.5", "eslint-plugin-prettier": "^5.5.5", "prettier": "^3.8.1", + "terser": "^5.46.0", "vite": "^7.3.1", "vite-plugin-node-polyfills": "^0.25.0", "xml2js": "^0.6.2" diff --git a/vite.config.js b/vite.config.js index 42bc16c08c..46c86d0141 100644 --- a/vite.config.js +++ b/vite.config.js @@ -18,9 +18,17 @@ const customConfig = { }, }, }, - define: { - '__APP_VERSION__': JSON.stringify(process.env.npm_package_version), - }, + define: { + '__APP_VERSION__': JSON.stringify(process.env.npm_package_version), + }, + build: { + // rollup-plugin-esbuild-minify runs in renderChunk (before Rollup resolves + // !~{NNN}~ placeholder hashes) and renames variables to short names that + // collide across chunk sections, causing vite:esbuild-transpile to fail with + // "'_s' is not declared in this file". Use terser instead: it runs in + // generateBundle after all placeholders are resolved. + minify: 'terser', + }, } export default createAppConfig( { @@ -34,5 +42,6 @@ export default createAppConfig( inlineCSS: { relativeCSSInjection: true }, config: customConfig, assetsPrefix: `polls-${process.env.npm_package_version}-`, + minify: false, // disable rollup-plugin-esbuild-minify; terser is used instead (see above) }, ) From 9583aee3476c1b2c9c61b4bf4cda30262127130a Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 6 Mar 2026 19:50:24 +0100 Subject: [PATCH 05/20] fix test Signed-off-by: dartcafe --- lib/Service/DiffService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Service/DiffService.php b/lib/Service/DiffService.php index c0cc448253..cbdfbff532 100644 --- a/lib/Service/DiffService.php +++ b/lib/Service/DiffService.php @@ -103,12 +103,12 @@ private function extractNewValues($diff, &$newValues): void { foreach ($diff as $key => $value) { if (is_array($value)) { // If 'new' exists, add it to the correct position in the structure - if (isset($value['new'])) { + if (array_key_exists('new', $value)) { $newValues[$key] = $value['new']; } // If the element is an array and the key isn't set yet, initialize it - if (!isset($newValues[$key])) { + if (!array_key_exists($key, $newValues)) { $newValues[$key] = []; } From cedb61e8a839b16684ab27d7ca0aaea9fc77d37c Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 6 Mar 2026 20:34:58 +0100 Subject: [PATCH 06/20] add db mapper tests Signed-off-by: dartcafe --- tests/Unit/Db/PollGroupMapperTest.php | 96 ++++++++++++++++ tests/Unit/Db/PreferencesMapperTest.php | 63 +++++++++++ tests/Unit/Db/ShareMapperTest.php | 119 ++++++++++++++++++++ tests/Unit/Db/UserMapperTest.php | 103 +++++++++++++++++ tests/Unit/Db/WatchMapperTest.php | 100 ++++++++++++++++ tests/Unit/Factories/PollGroupFactory.php | 11 ++ tests/Unit/Factories/PreferencesFactory.php | 6 +- tests/Unit/Factories/ShareFactory.php | 16 +-- tests/Unit/Factories/WatchFactory.php | 16 +++ 9 files changed, 520 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/Db/PollGroupMapperTest.php create mode 100644 tests/Unit/Db/PreferencesMapperTest.php create mode 100644 tests/Unit/Db/ShareMapperTest.php create mode 100644 tests/Unit/Db/UserMapperTest.php create mode 100644 tests/Unit/Db/WatchMapperTest.php create mode 100644 tests/Unit/Factories/PollGroupFactory.php create mode 100644 tests/Unit/Factories/WatchFactory.php diff --git a/tests/Unit/Db/PollGroupMapperTest.php b/tests/Unit/Db/PollGroupMapperTest.php new file mode 100644 index 0000000000..f19e05fd26 --- /dev/null +++ b/tests/Unit/Db/PollGroupMapperTest.php @@ -0,0 +1,96 @@ +set('ncPollsUserId', 'TestUser'); + + $this->pollGroupMapper = Server::get(PollGroupMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $this->polls[] = $this->pollMapper->insert($poll); + + for ($count = 0; $count < 2; $count++) { + /** @var PollGroup $group */ + $group = $this->fm->instance('OCA\Polls\Db\PollGroup'); + $this->groups[] = $this->pollGroupMapper->add($group); + } + } + + public function testList(): void { + $list = $this->pollGroupMapper->list(); + $this->assertGreaterThan(0, count($list)); + $this->assertContainsOnlyInstancesOf(PollGroup::class, $list); + } + + public function testFind(): void { + foreach ($this->groups as $group) { + $this->assertInstanceOf(PollGroup::class, $this->pollGroupMapper->find($group->getId())); + } + } + + public function testAddPollToGroup(): void { + $this->expectNotToPerformAssertions(); + $this->pollGroupMapper->addPollToGroup($this->polls[0]->getId(), $this->groups[0]->getId()); + } + + public function testRemovePollFromGroup(): void { + $this->expectNotToPerformAssertions(); + $this->pollGroupMapper->addPollToGroup($this->polls[0]->getId(), $this->groups[0]->getId()); + $this->pollGroupMapper->removePollFromGroup($this->polls[0]->getId(), $this->groups[0]->getId()); + } + + public function testTidyPollGroups(): void { + // groups have no associated polls → tidyPollGroups should delete them all + $this->pollGroupMapper->tidyPollGroups(); + $this->groups = []; + $this->expectNotToPerformAssertions(); + } + + public function testUpdate(): void { + foreach ($this->groups as &$group) { + $group->setTitle('Updated Title'); + $this->assertInstanceOf(PollGroup::class, $this->pollGroupMapper->update($group)); + } + unset($group); + } + + public function tearDown(): void { + parent::tearDown(); + foreach ($this->groups as $group) { + try { + $this->pollGroupMapper->delete($group); + } catch (\Exception $e) { + // already deleted (e.g. by testTidyPollGroups) + } + } + foreach ($this->polls as $poll) { + $this->pollMapper->delete($poll); + } + } +} diff --git a/tests/Unit/Db/PreferencesMapperTest.php b/tests/Unit/Db/PreferencesMapperTest.php new file mode 100644 index 0000000000..a26fe5c1ad --- /dev/null +++ b/tests/Unit/Db/PreferencesMapperTest.php @@ -0,0 +1,63 @@ +preferencesMapper = Server::get(PreferencesMapper::class); + + for ($count = 0; $count < 2; $count++) { + /** @var Preferences $prefs */ + $prefs = $this->fm->instance('OCA\Polls\Db\Preferences'); + array_push($this->preferences, $this->preferencesMapper->insert($prefs)); + } + } + + public function testFind(): void { + foreach ($this->preferences as $prefs) { + $this->assertInstanceOf( + Preferences::class, + $this->preferencesMapper->find($prefs->getUserId()) + ); + } + } + + public function testUpdate(): void { + foreach ($this->preferences as &$prefs) { + $prefs->setPreferences('{"updated":true}'); + $this->assertInstanceOf(Preferences::class, $this->preferencesMapper->update($prefs)); + } + unset($prefs); + } + + public function testDeleteByUserId(): void { + foreach ($this->preferences as $prefs) { + $this->expectNotToPerformAssertions(); + $this->preferencesMapper->deleteByUserId($prefs->getUserId()); + } + $this->preferences = []; + } + + public function tearDown(): void { + parent::tearDown(); + foreach ($this->preferences as $prefs) { + $this->preferencesMapper->deleteByUserId($prefs->getUserId()); + } + } +} diff --git a/tests/Unit/Db/ShareMapperTest.php b/tests/Unit/Db/ShareMapperTest.php new file mode 100644 index 0000000000..12d4db7106 --- /dev/null +++ b/tests/Unit/Db/ShareMapperTest.php @@ -0,0 +1,119 @@ +set('ncPollsUserId', 'TestUser'); + + $this->shareMapper = Server::get(ShareMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + $this->polls = [ + $this->fm->instance('OCA\Polls\Db\Poll'), + ]; + + foreach ($this->polls as &$poll) { + $poll = $this->pollMapper->insert($poll); + + for ($count = 0; $count < 2; $count++) { + /** @var Share $share */ + $share = $this->fm->instance('OCA\Polls\Db\Share'); + $share->setPollId($poll->getId()); + array_push($this->shares, $this->shareMapper->insert($share)); + } + } + unset($poll); + } + + public function testFindByPoll(): void { + foreach ($this->polls as $poll) { + $this->assertGreaterThan(0, count($this->shareMapper->findByPoll($poll->getId()))); + } + } + + public function testFindByToken(): void { + foreach ($this->shares as $share) { + $this->assertInstanceOf(Share::class, $this->shareMapper->findByToken($share->getToken())); + } + } + + public function testFindByPollAndUser(): void { + foreach ($this->shares as $share) { + $this->assertInstanceOf( + Share::class, + $this->shareMapper->findByPollAndUser($share->getPollId(), $share->getUserId()) + ); + } + } + + public function testFindByPollNotInvited(): void { + foreach ($this->polls as $poll) { + // shares were inserted with invitationSent = 0 + $this->assertGreaterThan(0, count($this->shareMapper->findByPollNotInvited($poll->getId()))); + } + } + + public function testFindByPollUnreminded(): void { + foreach ($this->polls as $poll) { + // reminderSent defaults to 0 + $this->assertGreaterThan(0, count($this->shareMapper->findByPollUnreminded($poll->getId()))); + } + } + + public function testUpdate(): void { + foreach ($this->shares as &$share) { + $share->setDisplayName('Updated Name'); + $this->assertInstanceOf(Share::class, $this->shareMapper->update($share)); + } + unset($share); + } + + public function testDelete(): void { + foreach ($this->shares as $share) { + $this->assertInstanceOf(Share::class, $this->shareMapper->delete($share)); + } + $this->shares = []; + } + + public function testDeleteByIdAndType(): void { + $this->expectNotToPerformAssertions(); + foreach ($this->shares as $share) { + $this->shareMapper->deleteByIdAndType($share->getUserId(), $share->getType()); + } + $this->shares = []; + } + + public function tearDown(): void { + parent::tearDown(); + foreach ($this->shares as $share) { + $this->shareMapper->delete($share); + } + foreach ($this->polls as $poll) { + $this->pollMapper->delete($poll); + } + } +} diff --git a/tests/Unit/Db/UserMapperTest.php b/tests/Unit/Db/UserMapperTest.php new file mode 100644 index 0000000000..3702bd12a0 --- /dev/null +++ b/tests/Unit/Db/UserMapperTest.php @@ -0,0 +1,103 @@ +userMapper = Server::get(UserMapper::class); + } + + // --- getUserObject: pure factory, no DB needed --- + + public function testGetUserObjectReturnsUser(): void { + $user = $this->userMapper->getUserObject(User::TYPE, 'user1'); + $this->assertInstanceOf(User::class, $user); + } + + public function testGetUserObjectReturnsAdmin(): void { + $user = $this->userMapper->getUserObject(Admin::TYPE, 'admin1'); + $this->assertInstanceOf(Admin::class, $user); + } + + public function testGetUserObjectReturnsGhost(): void { + $user = $this->userMapper->getUserObject(Ghost::TYPE, 'ghost1'); + $this->assertInstanceOf(Ghost::class, $user); + } + + public function testGetUserObjectReturnsGroup(): void { + $user = $this->userMapper->getUserObject(Group::TYPE, 'group1'); + $this->assertInstanceOf(Group::class, $user); + } + + public function testGetUserObjectReturnsEmail(): void { + $user = $this->userMapper->getUserObject(Email::TYPE, 'email_user', 'Display', 'test@example.com', 'en'); + $this->assertInstanceOf(Email::class, $user); + } + + public function testGetUserObjectReturnsContact(): void { + $user = $this->userMapper->getUserObject(Contact::TYPE, 'contact1'); + $this->assertInstanceOf(Contact::class, $user); + } + + public function testGetUserObjectReturnsContactGroup(): void { + $user = $this->userMapper->getUserObject(ContactGroup::TYPE, 'cg1'); + $this->assertInstanceOf(ContactGroup::class, $user); + } + + public function testGetUserObjectReturnsCircle(): void { + $user = $this->userMapper->getUserObject(Circle::TYPE, 'circle1'); + $this->assertInstanceOf(Circle::class, $user); + } + + public function testGetUserObjectReturnsExternalGenericUser(): void { + $user = $this->userMapper->getUserObject(UserBase::TYPE_EXTERNAL, 'ext1', 'External User'); + $this->assertInstanceOf(GenericUser::class, $user); + } + + public function testGetUserObjectReturnsPublicGenericUser(): void { + $user = $this->userMapper->getUserObject(UserBase::TYPE_PUBLIC, 'pub1', 'Public User'); + $this->assertInstanceOf(GenericUser::class, $user); + } + + public function testGetUserObjectThrowsForInvalidType(): void { + $this->expectException(InvalidShareTypeException::class); + $this->userMapper->getUserObject('invalid_type', 'user1'); + } + + // --- getParticipant: DB-touching paths --- + + public function testGetParticipantWithEmptyUserIdReturnsEmptyType(): void { + $user = $this->userMapper->getParticipant('', null); + $this->assertSame(UserBase::TYPE_EMPTY, $user->getType()); + } + + public function testGetParticipantWithNonExistentUserReturnsGhost(): void { + // User doesn't exist in NC, no share exists → falls back to Ghost + $user = $this->userMapper->getParticipant('nonexistent_user_xyzzy_' . bin2hex(random_bytes(4)), null); + $this->assertInstanceOf(Ghost::class, $user); + } +} diff --git a/tests/Unit/Db/WatchMapperTest.php b/tests/Unit/Db/WatchMapperTest.php new file mode 100644 index 0000000000..7cfe148e22 --- /dev/null +++ b/tests/Unit/Db/WatchMapperTest.php @@ -0,0 +1,100 @@ +watchMapper = Server::get(WatchMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + $this->userSession = Server::get(UserSession::class); + + $this->polls = [ + $this->fm->instance('OCA\Polls\Db\Poll'), + ]; + + foreach ($this->polls as &$poll) { + $poll = $this->pollMapper->insert($poll); + + // A watch with a different session_id: findUpdatesForPollId excludes the + // current session, so this one will appear in results. + /** @var Watch $watch */ + $watch = $this->fm->instance('OCA\Polls\Db\Watch'); + $watch->setPollId($poll->getId()); + $watch->setSessionId('test-session-different'); + array_push($this->watches, $this->watchMapper->insert($watch)); + + // A watch with the current session's hashed ID for findForPollIdAndTable. + /** @var Watch $ownWatch */ + $ownWatch = $this->fm->instance('OCA\Polls\Db\Watch'); + $ownWatch->setPollId($poll->getId()); + $ownWatch->setTable(Watch::OBJECT_OPTIONS); + $ownWatch->setSessionId($this->userSession->getClientIdHashed()); + array_push($this->watches, $this->watchMapper->insert($ownWatch)); + } + unset($poll); + } + + public function testInsert(): void { + foreach ($this->watches as $watch) { + $this->assertNotNull($watch->getId()); + } + } + + public function testFindUpdatesForPollId(): void { + foreach ($this->polls as $poll) { + $updates = $this->watchMapper->findUpdatesForPollId($poll->getId(), 0); + // The watch with 'test-session-different' must appear; + // the own-session watch is filtered out. + $this->assertGreaterThan(0, count($updates)); + } + } + + public function testFindForPollIdAndTable(): void { + foreach ($this->polls as $poll) { + $this->assertInstanceOf( + Watch::class, + $this->watchMapper->findForPollIdAndTable($poll->getId(), Watch::OBJECT_OPTIONS) + ); + } + } + + public function testDelete(): void { + foreach ($this->watches as $watch) { + $this->assertInstanceOf(Watch::class, $this->watchMapper->delete($watch)); + } + $this->watches = []; + } + + public function tearDown(): void { + parent::tearDown(); + foreach ($this->watches as $watch) { + $this->watchMapper->delete($watch); + } + foreach ($this->polls as $poll) { + $this->pollMapper->delete($poll); + } + } +} diff --git a/tests/Unit/Factories/PollGroupFactory.php b/tests/Unit/Factories/PollGroupFactory.php new file mode 100644 index 0000000000..a037972a71 --- /dev/null +++ b/tests/Unit/Factories/PollGroupFactory.php @@ -0,0 +1,11 @@ +define('OCA\Polls\Db\PollGroup')->setDefinitions([ + 'title' => Faker::text(50), + 'description' => Faker::text(255), +]); diff --git a/tests/Unit/Factories/PreferencesFactory.php b/tests/Unit/Factories/PreferencesFactory.php index aa87ab282b..e2adfb03d7 100644 --- a/tests/Unit/Factories/PreferencesFactory.php +++ b/tests/Unit/Factories/PreferencesFactory.php @@ -8,10 +8,12 @@ * General factory for the poll model. */ $fm->define('OCA\Polls\Db\Preferences')->setDefinitions([ - 'type' => 'textPoll', + 'userId' => function () { + return 'test_user_' . bin2hex(random_bytes(4)); + }, 'timestamp' => function () { $date = new DateTime('today'); return $date->getTimestamp(); }, - 'preferences' => '{"someJSON":0}' + 'preferences' => '{"someJSON":0}', ]); diff --git a/tests/Unit/Factories/ShareFactory.php b/tests/Unit/Factories/ShareFactory.php index d5fc1e2fb5..ab238108c3 100644 --- a/tests/Unit/Factories/ShareFactory.php +++ b/tests/Unit/Factories/ShareFactory.php @@ -4,17 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ use League\FactoryMuffin\Faker\Facade as Faker; +use OCA\Polls\Db\Share; -/** - * General factory for the poll model. - */ $fm->define('OCA\Polls\Db\Share')->setDefinitions([ + 'type' => Share::TYPE_USER, + 'token' => function () { + return bin2hex(random_bytes(16)); + }, 'userId' => Faker::firstNameMale(), 'emailAddress' => Faker::safeEmail(), - 'invitationSent' => function () { - $date = new DateTime('today'); - return $date->getTimestamp(); - }, 'displayName' => Faker::lastName(), - 'isNoUser' => 0, + 'invitationSent' => 0, + 'reminderSent' => 0, + 'deleted' => 0, ]); diff --git a/tests/Unit/Factories/WatchFactory.php b/tests/Unit/Factories/WatchFactory.php new file mode 100644 index 0000000000..f89db5feff --- /dev/null +++ b/tests/Unit/Factories/WatchFactory.php @@ -0,0 +1,16 @@ +define('OCA\Polls\Db\Watch')->setDefinitions([ + 'table' => Watch::OBJECT_VOTES, + 'updated' => function () { + return time(); + }, + 'sessionId' => function () { + return bin2hex(random_bytes(16)); + }, +]); From b4355b65dc019d2b2475b8937d944894d161877f Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 6 Mar 2026 20:35:10 +0100 Subject: [PATCH 07/20] remove Faker Signed-off-by: dartcafe --- tests/Unit/Db/CommentMapperTest.php | 4 +--- tests/Unit/Db/PollMapperTest.php | 7 ++----- tests/Unit/Factories/CommentFactory.php | 13 ++++++------- tests/Unit/Factories/LogFactory.php | 15 +++++++-------- tests/Unit/Factories/OptionFactory.php | 13 ++++++------- tests/Unit/Factories/PollFactory.php | 17 ++++++++++------- tests/Unit/Factories/ShareFactory.php | 13 +++++++++---- tests/Unit/Factories/SubscriptionFactory.php | 9 +++------ tests/Unit/Factories/VoteFactory.php | 13 ++++++------- 9 files changed, 50 insertions(+), 54 deletions(-) diff --git a/tests/Unit/Db/CommentMapperTest.php b/tests/Unit/Db/CommentMapperTest.php index f3b2796df1..ec2d8ca8af 100644 --- a/tests/Unit/Db/CommentMapperTest.php +++ b/tests/Unit/Db/CommentMapperTest.php @@ -6,7 +6,6 @@ namespace OCA\Polls\Tests\Unit\Db; -use League\FactoryMuffin\Faker\Facade as Faker; use OCA\Polls\Tests\Unit\UnitTestCase; use OCA\Polls\Db\Comment; @@ -75,8 +74,7 @@ public function testFindByPoll() { */ public function testUpdate() { foreach ($this->comments as &$comment) { - $newComment = Faker::paragraph(); - $comment->setComment($newComment()); + $comment->setComment(bin2hex(random_bytes(64))); $this->assertInstanceOf(Comment::class, $this->commentMapper->update($comment)); } unset($comment); diff --git a/tests/Unit/Db/PollMapperTest.php b/tests/Unit/Db/PollMapperTest.php index f85ac5d772..7683dbb31c 100644 --- a/tests/Unit/Db/PollMapperTest.php +++ b/tests/Unit/Db/PollMapperTest.php @@ -8,7 +8,6 @@ namespace OCA\Polls\Tests\Unit\Db; -use League\FactoryMuffin\Faker\Facade as Faker; use OCA\Polls\Db\Poll; use OCA\Polls\Db\PollMapper; use OCA\Polls\Tests\Unit\UnitTestCase; @@ -42,10 +41,8 @@ protected function setUp(): void { */ public function testUpdate() { foreach ($this->polls as &$poll) { - $newTitle = Faker::sentence(10); - $newDescription = Faker::paragraph(); - $poll->setTitle($newTitle()); - $poll->setDescription($newDescription()); + $poll->setTitle(bin2hex(random_bytes(16))); + $poll->setDescription(bin2hex(random_bytes(64))); $this->assertInstanceOf(Poll::class, $this->pollMapper->update($poll)); } diff --git a/tests/Unit/Factories/CommentFactory.php b/tests/Unit/Factories/CommentFactory.php index 37621864f8..0a8c72dfb4 100644 --- a/tests/Unit/Factories/CommentFactory.php +++ b/tests/Unit/Factories/CommentFactory.php @@ -4,16 +4,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - -/** - * General factory for the comment model. - */ $fm->define('OCA\Polls\Db\Comment')->setDefinitions([ - 'userId' => Faker::firstNameMale(), + 'userId' => function () { + return bin2hex(random_bytes(8)); + }, 'timestamp' => function () { $date = new DateTime('today'); return $date->getTimestamp(); }, - 'comment' => Faker::text(255), + 'comment' => function () { + return bin2hex(random_bytes(64)); + }, ]); diff --git a/tests/Unit/Factories/LogFactory.php b/tests/Unit/Factories/LogFactory.php index 9c6936c78f..9a9966755e 100644 --- a/tests/Unit/Factories/LogFactory.php +++ b/tests/Unit/Factories/LogFactory.php @@ -4,18 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - -/** - * General factory for the comment model. - */ $fm->define('OCA\Polls\Db\Log')->setDefinitions([ 'created' => function () { $date = new DateTime('yesterday'); return $date->getTimestamp(); }, 'processed' => 0, - 'userId' => Faker::firstNameMale(), - 'displayName' => Faker::lastName(), - 'messageId' => 'addPoll' + 'userId' => function () { + return bin2hex(random_bytes(8)); + }, + 'displayName' => function () { + return bin2hex(random_bytes(8)); + }, + 'messageId' => 'addPoll', ]); diff --git a/tests/Unit/Factories/OptionFactory.php b/tests/Unit/Factories/OptionFactory.php index 938cc33286..56fac19a01 100644 --- a/tests/Unit/Factories/OptionFactory.php +++ b/tests/Unit/Factories/OptionFactory.php @@ -4,18 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - -/** - * General factory for the text model. - */ $fm->define('OCA\Polls\Db\Option')->setDefinitions([ - 'owner' => Faker::firstNameMale(), + 'owner' => function () { + return bin2hex(random_bytes(8)); + }, 'released' => function () { $date = new DateTime('now'); return $date->getTimestamp(); }, - 'pollOptionText' => Faker::text(255), + 'pollOptionText' => function () { + return bin2hex(random_bytes(64)); + }, 'timestamp' => 0, 'isoTimestamp' => null, 'isoDuration' => null, diff --git a/tests/Unit/Factories/PollFactory.php b/tests/Unit/Factories/PollFactory.php index 7ce5f343ba..1d17525310 100644 --- a/tests/Unit/Factories/PollFactory.php +++ b/tests/Unit/Factories/PollFactory.php @@ -4,17 +4,20 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; use OCA\Polls\Db\Poll; -/** - * General factory for the poll model. - */ + $fm->define('OCA\Polls\Db\Poll')->setDefinitions([ 'type' => 'textPoll', - 'title' => Faker::text(124), + 'title' => function () { + return bin2hex(random_bytes(16)); + }, 'votingVariant' => Poll::VARIANT_SIMPLE, - 'description' => Faker::text(255), - 'owner' => Faker::firstNameMale(), + 'description' => function () { + return bin2hex(random_bytes(64)); + }, + 'owner' => function () { + return bin2hex(random_bytes(8)); + }, 'created' => function () { $date = new DateTime('today'); return $date->getTimestamp(); diff --git a/tests/Unit/Factories/ShareFactory.php b/tests/Unit/Factories/ShareFactory.php index ab238108c3..da953d161d 100644 --- a/tests/Unit/Factories/ShareFactory.php +++ b/tests/Unit/Factories/ShareFactory.php @@ -3,7 +3,6 @@ * SPDX-FileCopyrightText: 2017 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; use OCA\Polls\Db\Share; $fm->define('OCA\Polls\Db\Share')->setDefinitions([ @@ -11,9 +10,15 @@ 'token' => function () { return bin2hex(random_bytes(16)); }, - 'userId' => Faker::firstNameMale(), - 'emailAddress' => Faker::safeEmail(), - 'displayName' => Faker::lastName(), + 'userId' => function () { + return bin2hex(random_bytes(8)); + }, + 'emailAddress' => function () { + return bin2hex(random_bytes(8)) . '@example.com'; + }, + 'displayName' => function () { + return bin2hex(random_bytes(8)); + }, 'invitationSent' => 0, 'reminderSent' => 0, 'deleted' => 0, diff --git a/tests/Unit/Factories/SubscriptionFactory.php b/tests/Unit/Factories/SubscriptionFactory.php index 16e8f022b1..b4df6de5e1 100644 --- a/tests/Unit/Factories/SubscriptionFactory.php +++ b/tests/Unit/Factories/SubscriptionFactory.php @@ -3,11 +3,8 @@ * SPDX-FileCopyrightText: 2017 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - -/** - * General factory for the Subscription model. - */ $fm->define('OCA\Polls\Db\Subscription')->setDefinitions([ - 'userId' => Faker::firstNameMale() + 'userId' => function () { + return bin2hex(random_bytes(8)); + }, ]); diff --git a/tests/Unit/Factories/VoteFactory.php b/tests/Unit/Factories/VoteFactory.php index b063e8761e..46dfa6284a 100644 --- a/tests/Unit/Factories/VoteFactory.php +++ b/tests/Unit/Factories/VoteFactory.php @@ -3,13 +3,12 @@ * SPDX-FileCopyrightText: 2017 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - -/** - * General factory for the vote model. - */ $fm->define('OCA\Polls\Db\Vote')->setDefinitions([ - 'userId' => Faker::firstNameMale(), - 'voteOptionText' => Faker::text(255), + 'userId' => function () { + return bin2hex(random_bytes(8)); + }, + 'voteOptionText' => function () { + return bin2hex(random_bytes(64)); + }, 'voteAnswer' => 'yes', ]); From 946756898fd9386578922231457c6952a4b80bdf Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 6 Mar 2026 21:00:13 +0100 Subject: [PATCH 08/20] let's see Signed-off-by: dartcafe --- .github/workflows/phpunit-mariadb.yml | 18 +++++ .github/workflows/phpunit-mysql.yml | 18 +++++ .github/workflows/phpunit-pgsql.yml | 18 +++++ .github/workflows/phpunit-sqlite.yml | 18 +++++ tests/Unit/Db/UserMapperTest.php | 85 ++++++++++++++++++----- tests/Unit/Factories/PollGroupFactory.php | 10 +-- 6 files changed, 146 insertions(+), 21 deletions(-) diff --git a/.github/workflows/phpunit-mariadb.yml b/.github/workflows/phpunit-mariadb.yml index ef12016ee0..36e334e53e 100644 --- a/.github/workflows/phpunit-mariadb.yml +++ b/.github/workflows/phpunit-mariadb.yml @@ -104,6 +104,22 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} + - name: Checkout contacts app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: nextcloud/contacts + ref: ${{ matrix.server-versions }} + path: apps/contacts + + - name: Checkout circles app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: nextcloud/circles + ref: ${{ matrix.server-versions }} + path: apps/circles + - name: Set up php ${{ matrix.php-versions }} uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 with: @@ -138,6 +154,8 @@ jobs: mkdir data ./occ maintenance:install --verbose --database=mysql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin ./occ app:enable --force ${{ env.APP_NAME }} + ./occ app:enable contacts + ./occ app:enable circles - name: Check PHPUnit script is defined id: check_phpunit diff --git a/.github/workflows/phpunit-mysql.yml b/.github/workflows/phpunit-mysql.yml index f84554b91a..c47874294b 100644 --- a/.github/workflows/phpunit-mysql.yml +++ b/.github/workflows/phpunit-mysql.yml @@ -102,6 +102,22 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} + - name: Checkout contacts app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: nextcloud/contacts + ref: ${{ matrix.server-versions }} + path: apps/contacts + + - name: Checkout circles app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: nextcloud/circles + ref: ${{ matrix.server-versions }} + path: apps/circles + - name: Set up php ${{ matrix.php-versions }} uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 with: @@ -136,6 +152,8 @@ jobs: mkdir data ./occ maintenance:install --verbose --database=mysql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin ./occ app:enable --force ${{ env.APP_NAME }} + ./occ app:enable contacts + ./occ app:enable circles - name: Check PHPUnit script is defined id: check_phpunit diff --git a/.github/workflows/phpunit-pgsql.yml b/.github/workflows/phpunit-pgsql.yml index 5194c6a0a0..3d9f164b75 100644 --- a/.github/workflows/phpunit-pgsql.yml +++ b/.github/workflows/phpunit-pgsql.yml @@ -105,6 +105,22 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} + - name: Checkout contacts app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: nextcloud/contacts + ref: ${{ matrix.server-versions }} + path: apps/contacts + + - name: Checkout circles app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: nextcloud/circles + ref: ${{ matrix.server-versions }} + path: apps/circles + - name: Set up php ${{ matrix.php-versions }} uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 with: @@ -139,6 +155,8 @@ jobs: mkdir data ./occ maintenance:install --verbose --database=pgsql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin ./occ app:enable --force ${{ env.APP_NAME }} + ./occ app:enable contacts + ./occ app:enable circles - name: Check PHPUnit script is defined id: check_phpunit diff --git a/.github/workflows/phpunit-sqlite.yml b/.github/workflows/phpunit-sqlite.yml index e1c19e7ef4..d6ef2063e5 100644 --- a/.github/workflows/phpunit-sqlite.yml +++ b/.github/workflows/phpunit-sqlite.yml @@ -94,6 +94,22 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} + - name: Checkout contacts app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: nextcloud/contacts + ref: ${{ matrix.server-versions }} + path: apps/contacts + + - name: Checkout circles app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: nextcloud/circles + ref: ${{ matrix.server-versions }} + path: apps/circles + - name: Set up php ${{ matrix.php-versions }} uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 with: @@ -128,6 +144,8 @@ jobs: mkdir data ./occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin ./occ app:enable --force ${{ env.APP_NAME }} + ./occ app:enable contacts + ./occ app:enable circles - name: Check PHPUnit script is defined id: check_phpunit diff --git a/tests/Unit/Db/UserMapperTest.php b/tests/Unit/Db/UserMapperTest.php index 3702bd12a0..977f90d58c 100644 --- a/tests/Unit/Db/UserMapperTest.php +++ b/tests/Unit/Db/UserMapperTest.php @@ -8,6 +8,9 @@ namespace OCA\Polls\Tests\Unit\Db; +use OCA\Circles\Api\v1\Circles as CirclesApi; +use OCA\Circles\Model\Circle as CirclesCircle; +use OCA\DAV\CardDAV\CardDavBackend; use OCA\Polls\Db\UserMapper; use OCA\Polls\Exceptions\InvalidShareTypeException; use OCA\Polls\Model\Group\Circle; @@ -25,54 +28,102 @@ class UserMapperTest extends UnitTestCase { private UserMapper $userMapper; + private string $contactUid = ''; + private int $contactBookId = 0; + private string $circleId = ''; protected function setUp(): void { parent::setUp(); $this->userMapper = Server::get(UserMapper::class); + + // Create a test contact for Contact/ContactGroup tests + if (Contact::isEnabled()) { + $backend = Server::get(CardDavBackend::class); + $books = $backend->getAddressBooksForUser('principals/users/admin'); + if (empty($books)) { + $backend->createAddressBook('principals/users/admin', 'contacts', []); + $books = $backend->getAddressBooksForUser('principals/users/admin'); + } + $this->contactBookId = (int)$books[0]['id']; + $this->contactUid = bin2hex(random_bytes(8)); + $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{$this->contactUid}\r\nFN:Test Contact\r\nEMAIL:test_{$this->contactUid}@polls.example.com\r\nEND:VCARD"; + $backend->createCard($this->contactBookId, $this->contactUid . '.vcf', $vcard); + } + + // Create a test circle for Circle tests + if (Circle::isEnabled()) { + \OC_User::setUserId('admin'); + $circle = CirclesApi::createCircle(CirclesCircle::CIRCLES_PERSONAL, 'TestPollsCircle_' . bin2hex(random_bytes(4))); + $this->circleId = $circle->getUniqueId(); + } + } + + protected function tearDown(): void { + parent::tearDown(); + + if ($this->contactBookId > 0 && $this->contactUid !== '') { + $backend = Server::get(CardDavBackend::class); + $backend->deleteCard($this->contactBookId, $this->contactUid . '.vcf'); + } + + if ($this->circleId !== '') { + try { + CirclesApi::destroyCircle($this->circleId); + } catch (\Exception $e) { + // ignore cleanup errors + } + } } - // --- getUserObject: pure factory, no DB needed --- + // --- getUserObject --- + // User/Admin use 'admin' (created by NC install in CI). + // Group uses 'admin' group (also created by NC install). + // Contact/ContactGroup use a vCard created in setUp() via CardDavBackend. + // Circle uses a circle created in setUp() via the Circles v1 API. public function testGetUserObjectReturnsUser(): void { - $user = $this->userMapper->getUserObject(User::TYPE, 'user1'); + $user = $this->userMapper->getUserObject(User::TYPE, 'admin'); $this->assertInstanceOf(User::class, $user); } public function testGetUserObjectReturnsAdmin(): void { - $user = $this->userMapper->getUserObject(Admin::TYPE, 'admin1'); + $user = $this->userMapper->getUserObject(Admin::TYPE, 'admin'); $this->assertInstanceOf(Admin::class, $user); } - public function testGetUserObjectReturnsGhost(): void { - $user = $this->userMapper->getUserObject(Ghost::TYPE, 'ghost1'); - $this->assertInstanceOf(Ghost::class, $user); - } - public function testGetUserObjectReturnsGroup(): void { - $user = $this->userMapper->getUserObject(Group::TYPE, 'group1'); + $user = $this->userMapper->getUserObject(Group::TYPE, 'admin'); $this->assertInstanceOf(Group::class, $user); } - public function testGetUserObjectReturnsEmail(): void { - $user = $this->userMapper->getUserObject(Email::TYPE, 'email_user', 'Display', 'test@example.com', 'en'); - $this->assertInstanceOf(Email::class, $user); - } - public function testGetUserObjectReturnsContact(): void { - $user = $this->userMapper->getUserObject(Contact::TYPE, 'contact1'); + $this->assertNotEmpty($this->contactUid, 'Contacts app not enabled or contact creation failed'); + $user = $this->userMapper->getUserObject(Contact::TYPE, $this->contactUid); $this->assertInstanceOf(Contact::class, $user); } public function testGetUserObjectReturnsContactGroup(): void { - $user = $this->userMapper->getUserObject(ContactGroup::TYPE, 'cg1'); + // ContactGroup constructor only checks isEnabled() — no data lookup needed + $user = $this->userMapper->getUserObject(ContactGroup::TYPE, 'TestGroup'); $this->assertInstanceOf(ContactGroup::class, $user); } public function testGetUserObjectReturnsCircle(): void { - $user = $this->userMapper->getUserObject(Circle::TYPE, 'circle1'); + $this->assertNotEmpty($this->circleId, 'Circles app not enabled or circle creation failed'); + $user = $this->userMapper->getUserObject(Circle::TYPE, $this->circleId); $this->assertInstanceOf(Circle::class, $user); } + public function testGetUserObjectReturnsGhost(): void { + $user = $this->userMapper->getUserObject(Ghost::TYPE, 'ghost1'); + $this->assertInstanceOf(Ghost::class, $user); + } + + public function testGetUserObjectReturnsEmail(): void { + $user = $this->userMapper->getUserObject(Email::TYPE, 'email_user', 'Display', 'test@example.com', 'en'); + $this->assertInstanceOf(Email::class, $user); + } + public function testGetUserObjectReturnsExternalGenericUser(): void { $user = $this->userMapper->getUserObject(UserBase::TYPE_EXTERNAL, 'ext1', 'External User'); $this->assertInstanceOf(GenericUser::class, $user); diff --git a/tests/Unit/Factories/PollGroupFactory.php b/tests/Unit/Factories/PollGroupFactory.php index a037972a71..7b7737a41e 100644 --- a/tests/Unit/Factories/PollGroupFactory.php +++ b/tests/Unit/Factories/PollGroupFactory.php @@ -3,9 +3,11 @@ * SPDX-FileCopyrightText: 2026 Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -use League\FactoryMuffin\Faker\Facade as Faker; - $fm->define('OCA\Polls\Db\PollGroup')->setDefinitions([ - 'title' => Faker::text(50), - 'description' => Faker::text(255), + 'title' => function () { + return bin2hex(random_bytes(16)); + }, + 'description' => function () { + return bin2hex(random_bytes(64)); + }, ]); From 34e4ae7f5ff2b089e31db79ed963f2b7044475ae Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 6 Mar 2026 21:53:48 +0100 Subject: [PATCH 09/20] ignore Teams and Contacts for the moment Signed-off-by: dartcafe --- .github/workflows/phpunit-mariadb.yml | 18 ------- .github/workflows/phpunit-mysql.yml | 18 ------- .github/workflows/phpunit-pgsql.yml | 18 ------- .github/workflows/phpunit-sqlite.yml | 18 ------- tests/Unit/Db/UserMapperTest.php | 68 +-------------------------- 5 files changed, 1 insertion(+), 139 deletions(-) diff --git a/.github/workflows/phpunit-mariadb.yml b/.github/workflows/phpunit-mariadb.yml index 36e334e53e..ef12016ee0 100644 --- a/.github/workflows/phpunit-mariadb.yml +++ b/.github/workflows/phpunit-mariadb.yml @@ -104,22 +104,6 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} - - name: Checkout contacts app - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - repository: nextcloud/contacts - ref: ${{ matrix.server-versions }} - path: apps/contacts - - - name: Checkout circles app - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - repository: nextcloud/circles - ref: ${{ matrix.server-versions }} - path: apps/circles - - name: Set up php ${{ matrix.php-versions }} uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 with: @@ -154,8 +138,6 @@ jobs: mkdir data ./occ maintenance:install --verbose --database=mysql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin ./occ app:enable --force ${{ env.APP_NAME }} - ./occ app:enable contacts - ./occ app:enable circles - name: Check PHPUnit script is defined id: check_phpunit diff --git a/.github/workflows/phpunit-mysql.yml b/.github/workflows/phpunit-mysql.yml index c47874294b..f84554b91a 100644 --- a/.github/workflows/phpunit-mysql.yml +++ b/.github/workflows/phpunit-mysql.yml @@ -102,22 +102,6 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} - - name: Checkout contacts app - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - repository: nextcloud/contacts - ref: ${{ matrix.server-versions }} - path: apps/contacts - - - name: Checkout circles app - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - repository: nextcloud/circles - ref: ${{ matrix.server-versions }} - path: apps/circles - - name: Set up php ${{ matrix.php-versions }} uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 with: @@ -152,8 +136,6 @@ jobs: mkdir data ./occ maintenance:install --verbose --database=mysql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin ./occ app:enable --force ${{ env.APP_NAME }} - ./occ app:enable contacts - ./occ app:enable circles - name: Check PHPUnit script is defined id: check_phpunit diff --git a/.github/workflows/phpunit-pgsql.yml b/.github/workflows/phpunit-pgsql.yml index 3d9f164b75..5194c6a0a0 100644 --- a/.github/workflows/phpunit-pgsql.yml +++ b/.github/workflows/phpunit-pgsql.yml @@ -105,22 +105,6 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} - - name: Checkout contacts app - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - repository: nextcloud/contacts - ref: ${{ matrix.server-versions }} - path: apps/contacts - - - name: Checkout circles app - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - repository: nextcloud/circles - ref: ${{ matrix.server-versions }} - path: apps/circles - - name: Set up php ${{ matrix.php-versions }} uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 with: @@ -155,8 +139,6 @@ jobs: mkdir data ./occ maintenance:install --verbose --database=pgsql --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin ./occ app:enable --force ${{ env.APP_NAME }} - ./occ app:enable contacts - ./occ app:enable circles - name: Check PHPUnit script is defined id: check_phpunit diff --git a/.github/workflows/phpunit-sqlite.yml b/.github/workflows/phpunit-sqlite.yml index d6ef2063e5..e1c19e7ef4 100644 --- a/.github/workflows/phpunit-sqlite.yml +++ b/.github/workflows/phpunit-sqlite.yml @@ -94,22 +94,6 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} - - name: Checkout contacts app - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - repository: nextcloud/contacts - ref: ${{ matrix.server-versions }} - path: apps/contacts - - - name: Checkout circles app - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - repository: nextcloud/circles - ref: ${{ matrix.server-versions }} - path: apps/circles - - name: Set up php ${{ matrix.php-versions }} uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 with: @@ -144,8 +128,6 @@ jobs: mkdir data ./occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin ./occ app:enable --force ${{ env.APP_NAME }} - ./occ app:enable contacts - ./occ app:enable circles - name: Check PHPUnit script is defined id: check_phpunit diff --git a/tests/Unit/Db/UserMapperTest.php b/tests/Unit/Db/UserMapperTest.php index 977f90d58c..9cba09b9e4 100644 --- a/tests/Unit/Db/UserMapperTest.php +++ b/tests/Unit/Db/UserMapperTest.php @@ -8,16 +8,10 @@ namespace OCA\Polls\Tests\Unit\Db; -use OCA\Circles\Api\v1\Circles as CirclesApi; -use OCA\Circles\Model\Circle as CirclesCircle; -use OCA\DAV\CardDAV\CardDavBackend; use OCA\Polls\Db\UserMapper; use OCA\Polls\Exceptions\InvalidShareTypeException; -use OCA\Polls\Model\Group\Circle; -use OCA\Polls\Model\Group\ContactGroup; use OCA\Polls\Model\Group\Group; use OCA\Polls\Model\User\Admin; -use OCA\Polls\Model\User\Contact; use OCA\Polls\Model\User\Email; use OCA\Polls\Model\User\GenericUser; use OCA\Polls\Model\User\Ghost; @@ -28,58 +22,16 @@ class UserMapperTest extends UnitTestCase { private UserMapper $userMapper; - private string $contactUid = ''; - private int $contactBookId = 0; - private string $circleId = ''; protected function setUp(): void { parent::setUp(); $this->userMapper = Server::get(UserMapper::class); - - // Create a test contact for Contact/ContactGroup tests - if (Contact::isEnabled()) { - $backend = Server::get(CardDavBackend::class); - $books = $backend->getAddressBooksForUser('principals/users/admin'); - if (empty($books)) { - $backend->createAddressBook('principals/users/admin', 'contacts', []); - $books = $backend->getAddressBooksForUser('principals/users/admin'); - } - $this->contactBookId = (int)$books[0]['id']; - $this->contactUid = bin2hex(random_bytes(8)); - $vcard = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:{$this->contactUid}\r\nFN:Test Contact\r\nEMAIL:test_{$this->contactUid}@polls.example.com\r\nEND:VCARD"; - $backend->createCard($this->contactBookId, $this->contactUid . '.vcf', $vcard); - } - - // Create a test circle for Circle tests - if (Circle::isEnabled()) { - \OC_User::setUserId('admin'); - $circle = CirclesApi::createCircle(CirclesCircle::CIRCLES_PERSONAL, 'TestPollsCircle_' . bin2hex(random_bytes(4))); - $this->circleId = $circle->getUniqueId(); - } - } - - protected function tearDown(): void { - parent::tearDown(); - - if ($this->contactBookId > 0 && $this->contactUid !== '') { - $backend = Server::get(CardDavBackend::class); - $backend->deleteCard($this->contactBookId, $this->contactUid . '.vcf'); - } - - if ($this->circleId !== '') { - try { - CirclesApi::destroyCircle($this->circleId); - } catch (\Exception $e) { - // ignore cleanup errors - } - } } // --- getUserObject --- // User/Admin use 'admin' (created by NC install in CI). // Group uses 'admin' group (also created by NC install). - // Contact/ContactGroup use a vCard created in setUp() via CardDavBackend. - // Circle uses a circle created in setUp() via the Circles v1 API. + // Contact/ContactGroup/Circle require external apps — tested separately once available. public function testGetUserObjectReturnsUser(): void { $user = $this->userMapper->getUserObject(User::TYPE, 'admin'); @@ -96,24 +48,6 @@ public function testGetUserObjectReturnsGroup(): void { $this->assertInstanceOf(Group::class, $user); } - public function testGetUserObjectReturnsContact(): void { - $this->assertNotEmpty($this->contactUid, 'Contacts app not enabled or contact creation failed'); - $user = $this->userMapper->getUserObject(Contact::TYPE, $this->contactUid); - $this->assertInstanceOf(Contact::class, $user); - } - - public function testGetUserObjectReturnsContactGroup(): void { - // ContactGroup constructor only checks isEnabled() — no data lookup needed - $user = $this->userMapper->getUserObject(ContactGroup::TYPE, 'TestGroup'); - $this->assertInstanceOf(ContactGroup::class, $user); - } - - public function testGetUserObjectReturnsCircle(): void { - $this->assertNotEmpty($this->circleId, 'Circles app not enabled or circle creation failed'); - $user = $this->userMapper->getUserObject(Circle::TYPE, $this->circleId); - $this->assertInstanceOf(Circle::class, $user); - } - public function testGetUserObjectReturnsGhost(): void { $user = $this->userMapper->getUserObject(Ghost::TYPE, 'ghost1'); $this->assertInstanceOf(Ghost::class, $user); From d999622588823b773eb7b240f8f2f0eccbd1d298 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Fri, 6 Mar 2026 23:06:52 +0100 Subject: [PATCH 10/20] add some service tests Signed-off-by: dartcafe --- tests/Unit/Service/OptionServiceTest.php | 208 ++++++++++++++++++++++ tests/Unit/Service/PollServiceTest.php | 191 +++++++++++++++++++++ tests/Unit/Service/VoteServiceTest.php | 209 +++++++++++++++++++++++ 3 files changed, 608 insertions(+) create mode 100644 tests/Unit/Service/OptionServiceTest.php create mode 100644 tests/Unit/Service/PollServiceTest.php create mode 100644 tests/Unit/Service/VoteServiceTest.php diff --git a/tests/Unit/Service/OptionServiceTest.php b/tests/Unit/Service/OptionServiceTest.php new file mode 100644 index 0000000000..acf6e6e256 --- /dev/null +++ b/tests/Unit/Service/OptionServiceTest.php @@ -0,0 +1,208 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->optionService = Server::get(OptionService::class); + $this->optionMapper = Server::get(OptionMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + // Text poll owned by admin, private, open + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $poll->setType(Poll::TYPE_TEXT); + $poll->setAccess(Poll::ACCESS_PRIVATE); + $poll->setExpire(0); + $this->textPoll = $this->pollMapper->insert($poll); + + // Date poll owned by admin, private, open + $datePoll = $this->fm->instance('OCA\Polls\Db\Poll'); + $datePoll->setOwner('admin'); + $datePoll->setType(Poll::TYPE_DATE); + $datePoll->setAccess(Poll::ACCESS_PRIVATE); + $datePoll->setExpire(0); + $this->datePoll = $this->pollMapper->insert($datePoll); + + // Pre-existing text option for update/delete/confirm/reorder tests + $this->textOption = $this->optionService->add( + $this->textPoll->getId(), + (new SimpleOption())->setText('Initial option') + ); + } + + protected function tearDown(): void { + parent::tearDown(); + // Options are deleted when their poll is deleted + try { + $this->pollMapper->delete($this->textPoll); + } catch (\Exception $e) { + } + try { + $this->pollMapper->delete($this->datePoll); + } catch (\Exception $e) { + } + } + + // --- add --- + + public function testAddTextOptionToTextPoll(): void { + $option = $this->optionService->add( + $this->textPoll->getId(), + (new SimpleOption())->setText('New Option') + ); + $this->assertInstanceOf(Option::class, $option); + $this->assertSame('New Option', $option->getPollOptionText()); + $this->assertSame($this->textPoll->getId(), $option->getPollId()); + } + + public function testAddDateOptionToDatePoll(): void { + $timestamp = strtotime('2027-06-15 12:00:00 UTC'); + $option = $this->optionService->add( + $this->datePoll->getId(), + (new SimpleOption())->setDateTime($timestamp) + ); + $this->assertInstanceOf(Option::class, $option); + $this->assertSame($this->datePoll->getId(), $option->getPollId()); + $this->assertSame($timestamp, $option->getTimestamp()); + } + + // --- addBulk --- + + public function testAddBulkAddsMultipleOptions(): void { + $bulkText = implode(PHP_EOL, ['Bulk A', 'Bulk B', 'Bulk C']); + $options = $this->optionService->addBulk($this->textPoll->getId(), $bulkText); + // setUp created 1 option; addBulk adds 3 more → at least 4 + $this->assertGreaterThanOrEqual(4, count($options)); + } + + public function testAddBulkDeduplicates(): void { + $countBefore = count($this->optionService->list($this->textPoll->getId())); + $bulkText = implode(PHP_EOL, ['Unique One', 'Unique One']); + $this->optionService->addBulk($this->textPoll->getId(), $bulkText); + $countAfter = count($this->optionService->list($this->textPoll->getId())); + // array_unique in addBulk means only 1 new option added despite 2 lines + $this->assertSame($countBefore + 1, $countAfter); + } + + // --- list --- + + public function testListReturnsOptionsForPoll(): void { + $options = $this->optionService->list($this->textPoll->getId()); + $this->assertNotEmpty($options); + foreach ($options as $option) { + $this->assertSame($this->textPoll->getId(), $option->getPollId()); + } + } + + public function testListReturnsEmptyArrayForPollWithNoOptions(): void { + $options = $this->optionService->list($this->datePoll->getId()); + $this->assertIsArray($options); + $this->assertEmpty($options); + } + + // --- update --- + + public function testUpdateTextOptionChangesText(): void { + $updated = $this->optionService->update($this->textOption->getId(), 'Updated Text'); + $this->assertSame('Updated Text', $updated->getPollOptionText()); + } + + public function testUpdateTextOptionThrowsOnEmptyText(): void { + $this->expectException(InsufficientAttributesException::class); + $this->optionService->update($this->textOption->getId(), ''); + } + + // --- delete / restore --- + + public function testDeleteSetsDeletedTimestamp(): void { + $deleted = $this->optionService->delete($this->textOption->getId()); + $this->assertGreaterThan(0, $deleted->getDeleted()); + } + + public function testRestoreClearsDeletedTimestamp(): void { + $this->optionService->delete($this->textOption->getId()); + $restored = $this->optionService->delete($this->textOption->getId(), true); + $this->assertSame(0, $restored->getDeleted()); + } + + // --- confirm --- + + public function testConfirmOptionOnExpiredPoll(): void { + // Poll must be expired for confirm permission + $this->textPoll->setExpire(time() - 3600); + $this->pollMapper->update($this->textPoll); + + $confirmed = $this->optionService->confirm($this->textOption->getId()); + $this->assertGreaterThan(0, $confirmed->getConfirmed()); + + // Restore: re-open the poll for tearDown + $this->textPoll->setExpire(0); + $this->pollMapper->update($this->textPoll); + } + + public function testConfirmTogglesConfirmation(): void { + $this->textPoll->setExpire(time() - 3600); + $this->pollMapper->update($this->textPoll); + + $this->optionService->confirm($this->textOption->getId()); // confirm + $unconfirmed = $this->optionService->confirm($this->textOption->getId()); // toggle back + $this->assertSame(0, $unconfirmed->getConfirmed()); + + $this->textPoll->setExpire(0); + $this->pollMapper->update($this->textPoll); + } + + // --- reorder --- + + public function testReorderChangesOptionOrder(): void { + $second = $this->optionService->add( + $this->textPoll->getId(), + (new SimpleOption())->setText('Second Option') + ); + + // Swap order: second first, initial second + $reordered = $this->optionService->reorder($this->textPoll->getId(), [ + ['id' => $second->getId()], + ['id' => $this->textOption->getId()], + ]); + + $this->assertCount(2, $reordered); + $orderById = []; + foreach ($reordered as $opt) { + $orderById[$opt->getId()] = $opt->getOrder(); + } + $this->assertSame(1, $orderById[$second->getId()]); + $this->assertSame(2, $orderById[$this->textOption->getId()]); + } +} diff --git a/tests/Unit/Service/PollServiceTest.php b/tests/Unit/Service/PollServiceTest.php new file mode 100644 index 0000000000..e0aa097b83 --- /dev/null +++ b/tests/Unit/Service/PollServiceTest.php @@ -0,0 +1,191 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->pollService = Server::get(PollService::class); + $this->pollMapper = Server::get(PollMapper::class); + + // Create a base poll for read/update/archive/close tests + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $this->poll = $this->pollMapper->insert($poll); + } + + protected function tearDown(): void { + parent::tearDown(); + try { + $this->pollMapper->delete($this->poll); + } catch (\Exception $e) { + // already deleted by test + } + } + + // --- add --- + + public function testAddCreatesTextPoll(): void { + $poll = $this->pollService->add(Poll::TYPE_TEXT, 'My Text Poll'); + $this->assertInstanceOf(Poll::class, $poll); + $this->assertSame(Poll::TYPE_TEXT, $poll->getType()); + $this->assertSame('My Text Poll', $poll->getTitle()); + $this->assertSame('admin', $poll->getOwner()); + $this->pollMapper->delete($poll); + } + + public function testAddCreatesDatePoll(): void { + $poll = $this->pollService->add(Poll::TYPE_DATE, 'My Date Poll'); + $this->assertInstanceOf(Poll::class, $poll); + $this->assertSame(Poll::TYPE_DATE, $poll->getType()); + $this->pollMapper->delete($poll); + } + + public function testAddThrowsOnInvalidType(): void { + $this->expectException(InvalidPollTypeException::class); + $this->pollService->add('invalidType', 'Title'); + } + + public function testAddThrowsOnEmptyTitle(): void { + $this->expectException(EmptyTitleException::class); + $this->pollService->add(Poll::TYPE_TEXT, ''); + } + + // --- get --- + + public function testGetReturnsExistingPoll(): void { + $result = $this->pollService->get($this->poll->getId()); + $this->assertInstanceOf(Poll::class, $result); + $this->assertSame($this->poll->getId(), $result->getId()); + } + + public function testGetThrowsOnNonExistentPoll(): void { + $this->expectException(NotFoundException::class); + $this->pollService->get(PHP_INT_MAX); + } + + // --- update --- + + public function testUpdateChangesTitle(): void { + $result = $this->pollService->update($this->poll->getId(), ['title' => 'Updated Title']); + $this->assertSame('Updated Title', $result['poll']->getTitle()); + } + + public function testUpdateReturnsDiff(): void { + $result = $this->pollService->update($this->poll->getId(), ['title' => 'Another Title']); + $this->assertArrayHasKey('poll', $result); + $this->assertArrayHasKey('diff', $result); + $this->assertArrayHasKey('changes', $result); + } + + public function testUpdateThrowsOnEmptyTitle(): void { + $this->expectException(EmptyTitleException::class); + $this->pollService->update($this->poll->getId(), ['title' => '']); + } + + public function testUpdateThrowsOnInvalidShowResults(): void { + $this->expectException(InvalidShowResultsException::class); + $this->pollService->update($this->poll->getId(), ['showResults' => 'invalidValue']); + } + + public function testUpdateChangesShowResults(): void { + $result = $this->pollService->update($this->poll->getId(), ['showResults' => Poll::SHOW_RESULTS_CLOSED]); + $this->assertSame(Poll::SHOW_RESULTS_CLOSED, $result['poll']->getShowResults()); + } + + // --- toggleArchive --- + + public function testToggleArchiveArchivesPoll(): void { + $result = $this->pollService->toggleArchive($this->poll->getId()); + $this->assertGreaterThan(0, $result->getDeleted()); + } + + public function testToggleArchiveRestoresPoll(): void { + // Archive first + $this->pollService->toggleArchive($this->poll->getId()); + // Then restore + $result = $this->pollService->toggleArchive($this->poll->getId()); + $this->assertSame(0, $result->getDeleted()); + } + + // --- close / reopen --- + + public function testCloseSetsExpiryInPast(): void { + $result = $this->pollService->close($this->poll->getId()); + $this->assertLessThan(time(), $result->getExpire()); + $this->assertGreaterThan(0, $result->getExpire()); + } + + public function testReopenClearsExpiry(): void { + $this->pollService->close($this->poll->getId()); + $result = $this->pollService->reopen($this->poll->getId()); + $this->assertSame(0, $result->getExpire()); + } + + // --- clone --- + + public function testCloneCreatesNewPoll(): void { + $clone = $this->pollService->clone($this->poll->getId()); + $this->assertInstanceOf(Poll::class, $clone); + $this->assertNotSame($this->poll->getId(), $clone->getId()); + $this->assertSame($this->poll->getTitle(), $clone->getTitle()); + $this->assertSame('admin', $clone->getOwner()); + $this->pollMapper->delete($clone); + } + + // --- delete --- + + public function testDeleteRemovesPoll(): void { + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $poll = $this->pollMapper->insert($poll); + + $result = $this->pollService->delete($poll->getId()); + $this->assertInstanceOf(Poll::class, $result); + + $this->expectException(NotFoundException::class); + $this->pollService->get($poll->getId()); + } + + public function testDeleteThrowsOnNonExistentPoll(): void { + $this->expectException(AlreadyDeletedException::class); + $this->pollService->delete(PHP_INT_MAX); + } + + // --- getValidEnum --- + + public function testGetValidEnumReturnsExpectedKeys(): void { + $enum = $this->pollService->getValidEnum(); + $this->assertArrayHasKey('pollType', $enum); + $this->assertArrayHasKey('access', $enum); + $this->assertArrayHasKey('showResults', $enum); + $this->assertContains(Poll::TYPE_TEXT, $enum['pollType']); + $this->assertContains(Poll::TYPE_DATE, $enum['pollType']); + } +} diff --git a/tests/Unit/Service/VoteServiceTest.php b/tests/Unit/Service/VoteServiceTest.php new file mode 100644 index 0000000000..76b4bbc912 --- /dev/null +++ b/tests/Unit/Service/VoteServiceTest.php @@ -0,0 +1,209 @@ +session = Server::get(ISession::class); + $this->pollMapper = Server::get(PollMapper::class); + $this->optionMapper = Server::get(OptionMapper::class); + $this->shareMapper = Server::get(ShareMapper::class); + $this->voteMapper = Server::get(VoteMapper::class); + $this->voteService = Server::get(VoteService::class); + $this->userSession = Server::get(UserSession::class); + + // Poll owned by admin, private access, voting open (useNo=0 is default) + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $poll->setAccess(Poll::ACCESS_PRIVATE); + $poll->setExpire(0); + $this->poll = $this->pollMapper->insert($poll); + + // Option belonging to the poll + $option = $this->fm->instance('OCA\Polls\Db\Option'); + $option->setPollId($this->poll->getId()); + $option->setOwner('admin'); + $this->option = $this->optionMapper->insert($option); + + // TYPE_USER share for admin — gives getCurrentUser() a User object (TYPE_USER) + $ownerShare = $this->fm->instance('OCA\Polls\Db\Share'); + $ownerShare->setPollId($this->poll->getId()); + $ownerShare->setType(Share::TYPE_USER); + $ownerShare->setUserId('admin'); + $this->ownerShare = $this->shareMapper->insert($ownerShare); + + // TYPE_EXTERNAL share for a simulated external participant + $externalShare = $this->fm->instance('OCA\Polls\Db\Share'); + $externalShare->setPollId($this->poll->getId()); + $externalShare->setType(Share::TYPE_EXTERNAL); + $externalShare->setUserId(self::EXTERNAL_USER_ID); + $this->externalShare = $this->shareMapper->insert($externalShare); + + $this->loginAsOwner(); + } + + protected function tearDown(): void { + parent::tearDown(); + $this->voteMapper->deleteByPollAndUserId($this->poll->getId(), 'admin'); + $this->voteMapper->deleteByPollAndUserId($this->poll->getId(), self::EXTERNAL_USER_ID); + $this->shareMapper->delete($this->ownerShare); + $this->shareMapper->delete($this->externalShare); + $this->pollMapper->delete($this->poll); + } + + // --- session helpers --- + + /** + * Login as poll owner via TYPE_USER share. + * UserSession resolves getCurrentUser() → User('admin') with TYPE_USER. + */ + private function loginAsOwner(): void { + $this->userSession->cleanSession(); + $this->session->set(UserSession::SESSION_KEY_SHARE_TOKEN, $this->ownerShare->getToken()); + $this->session->set(UserSession::SESSION_KEY_USER_ID, 'admin'); + } + + /** + * Login as external participant via TYPE_EXTERNAL share. + * UserSession resolves getCurrentUser() → GenericUser with TYPE_EXTERNAL. + */ + private function loginAsExternalUser(): void { + $this->userSession->cleanSession(); + $this->session->set(UserSession::SESSION_KEY_SHARE_TOKEN, $this->externalShare->getToken()); + $this->session->set(UserSession::SESSION_KEY_USER_ID, self::EXTERNAL_USER_ID); + } + + // --- set vote --- + + public function testOwnerCanSetVoteYes(): void { + $vote = $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $this->assertInstanceOf(Vote::class, $vote); + $this->assertSame(Vote::VOTE_YES, $vote->getVoteAnswer()); + $this->assertSame('admin', $vote->getUserId()); + $this->assertSame($this->poll->getId(), $vote->getPollId()); + } + + public function testOwnerCanSetVoteMaybe(): void { + $vote = $this->voteService->set($this->option->getId(), Vote::VOTE_EVENTUALLY); + $this->assertSame(Vote::VOTE_EVENTUALLY, $vote->getVoteAnswer()); + } + + public function testOwnerCanUpdateVoteFromYesToMaybe(): void { + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $updated = $this->voteService->set($this->option->getId(), Vote::VOTE_EVENTUALLY); + $this->assertSame(Vote::VOTE_EVENTUALLY, $updated->getVoteAnswer()); + } + + public function testSetSameAnswerIsIdempotent(): void { + $first = $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $second = $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + // Returns existing vote without change + $this->assertSame($first->getId(), $second->getId()); + $this->assertSame(Vote::VOTE_YES, $second->getVoteAnswer()); + } + + public function testSetVoteNoDeletesExistingVoteWhenUseNoIsFalse(): void { + // useNo=0 (default): setting 'no' on an existing vote deletes it + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $result = $this->voteService->set($this->option->getId(), Vote::VOTE_NO); + // Service returns the vote entity with empty answer after deletion + $this->assertSame('', $result->getVoteAnswer()); + } + + public function testSetVoteNoStoredWhenUseNoIsTrue(): void { + // Enable 'no' votes on the poll + $this->poll->setUseNo(1); + $this->pollMapper->update($this->poll); + + $vote = $this->voteService->set($this->option->getId(), Vote::VOTE_NO); + $this->assertSame(Vote::VOTE_NO, $vote->getVoteAnswer()); + + // Restore default + $this->poll->setUseNo(0); + $this->pollMapper->update($this->poll); + } + + // --- external user --- + + public function testExternalUserCanVote(): void { + $this->loginAsExternalUser(); + $vote = $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $this->assertInstanceOf(Vote::class, $vote); + $this->assertSame(Vote::VOTE_YES, $vote->getVoteAnswer()); + $this->assertSame(self::EXTERNAL_USER_ID, $vote->getUserId()); + } + + // --- list --- + + public function testOwnerCanListAllVotes(): void { + // Create votes for both owner and external user + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $this->loginAsExternalUser(); + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + + // Switch back to owner and list + $this->loginAsOwner(); + $votes = $this->voteService->list($this->poll->getId()); + $this->assertCount(2, $votes); + } + + // --- deleteUserFromPoll --- + + public function testOwnerCanDeleteOwnVotesFromPoll(): void { + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + $userId = $this->voteService->deleteUserFromPoll($this->poll->getId()); + $this->assertSame('admin', $userId); + + $remainingVotes = $this->voteMapper->findByPollAndUser($this->poll->getId(), 'admin'); + $this->assertEmpty($remainingVotes); + } + + public function testOwnerCanDeleteExternalUserVotesFromPoll(): void { + $this->loginAsExternalUser(); + $this->voteService->set($this->option->getId(), Vote::VOTE_YES); + + // Owner deletes the external user's votes + $this->loginAsOwner(); + $userId = $this->voteService->deleteUserFromPoll($this->poll->getId(), self::EXTERNAL_USER_ID); + $this->assertSame(self::EXTERNAL_USER_ID, $userId); + + $remainingVotes = $this->voteMapper->findByPollAndUser($this->poll->getId(), self::EXTERNAL_USER_ID); + $this->assertEmpty($remainingVotes); + } +} From 60599d50da430a381683e330d6a5250dd33ca536 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 7 Mar 2026 08:37:07 +0100 Subject: [PATCH 11/20] test coverage Signed-off-by: dartcafe --- .github/workflows/phpunit-coverage.yml | 108 +++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 .github/workflows/phpunit-coverage.yml diff --git a/.github/workflows/phpunit-coverage.yml b/.github/workflows/phpunit-coverage.yml new file mode 100644 index 0000000000..354e930957 --- /dev/null +++ b/.github/workflows/phpunit-coverage.yml @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: PHPUnit Coverage + +on: + push: + branches: + - master + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: phpunit-coverage-${{ github.ref }} + cancel-in-progress: true + +jobs: + matrix: + runs-on: ubuntu-latest-low + outputs: + php-version: ${{ steps.versions.outputs.php-max }} + server-version: ${{ steps.versions.outputs.branches-max }} + steps: + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Get version matrix + id: versions + uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2.3.1.3.2 + + coverage: + runs-on: ubuntu-latest + + needs: [matrix] + + name: Coverage PHP ${{ needs.matrix.outputs.php-version }} Nextcloud ${{ needs.matrix.outputs.server-version }} + + steps: + - name: Set app env + if: ${{ env.APP_NAME == '' }} + run: | + # Split and keep last + echo "APP_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV + + - name: Checkout server + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ needs.matrix.outputs.server-version }} + + - name: Checkout app + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + path: apps/${{ env.APP_NAME }} + + - name: Set up php ${{ needs.matrix.outputs.php-version }} + uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.36.0 + with: + php-version: ${{ needs.matrix.outputs.php-version }} + extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite + coverage: pcov + ini-file: development + ini-values: disable_functions= + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up dependencies + working-directory: apps/${{ env.APP_NAME }} + run: | + composer remove nextcloud/ocp --dev + composer i + + - name: Set up Nextcloud + run: | + mkdir data + ./occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --database-host=127.0.0.1 --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin + ./occ app:enable --force ${{ env.APP_NAME }} + + - name: PHPUnit with coverage + working-directory: apps/${{ env.APP_NAME }} + run: composer run test:unit -- --coverage-clover coverage.xml --coverage-text --colors=never 2>&1 | tee /tmp/coverage.txt + + - name: Write coverage summary + run: | + echo '## Test Coverage' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + grep -A 999 'Code Coverage Report' /tmp/coverage.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: apps/${{ env.APP_NAME }}/coverage.xml + retention-days: 7 + + - name: Print logs + if: always() + run: | + cat data/nextcloud.log From 26dc4c15e5ae2da372ba4ecb9f4d26dd93e4b341 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 7 Mar 2026 11:38:14 +0100 Subject: [PATCH 12/20] fix tests and poll cloning Signed-off-by: dartcafe --- lib/Db/Poll.php | 33 ++++++++++++++++++++++++ lib/Service/PollService.php | 4 +-- tests/Unit/Service/OptionServiceTest.php | 14 ++++++---- tests/Unit/Service/VoteServiceTest.php | 22 +++++++++------- 4 files changed, 56 insertions(+), 17 deletions(-) diff --git a/lib/Db/Poll.php b/lib/Db/Poll.php index 5a5418b8e9..84e6e88676 100644 --- a/lib/Db/Poll.php +++ b/lib/Db/Poll.php @@ -220,6 +220,39 @@ public function __clone() { $this->setAnonymous(0); } + /** + * Build a fresh Poll entity pre-populated from this poll's settings. + * The fresh entity's id is never set, so QBMapper::insert() relies on the + * DB autoincrement to assign a fresh id. + */ + public function createClone(string $newOwner): self { + $clone = new self(); + $clone->setType($this->getType()); + $clone->setVotingVariant($this->getVotingVariant()); + $clone->setTitle('Clone of ' . $this->getTitle()); + $clone->setDescription($this->getDescription()); + $clone->setOwner($newOwner); + $clone->setCreated(time()); + $clone->setLastInteraction(0); + $clone->setExpire($this->getExpire()); + $clone->setDeleted(0); + $clone->setAccess(self::ACCESS_PRIVATE); + $clone->setAnonymous(0); + $clone->setAllowComment($this->getAllowComment()); + $clone->setAllowMaybe($this->getAllowMaybe()); + $clone->setAllowProposals($this->getAllowProposals()); + $clone->setProposalsExpire($this->getProposalsExpire()); + $clone->setVoteLimit($this->getVoteLimit()); + $clone->setOptionLimit($this->getOptionLimit()); + $clone->setShowResults($this->getShowResults()); + $clone->setAdminAccess($this->getAdminAccess()); + $clone->setHideBookedUp($this->getHideBookedUp()); + $clone->setUseNo($this->getUseNo()); + $clone->setMiscSettings($this->getMiscSettings()); + $clone->setTimezoneName($this->getTimezoneName()); + return $clone; + } + /** * @return array * diff --git a/lib/Service/PollService.php b/lib/Service/PollService.php index 4f179fa0d8..1357acdd6d 100644 --- a/lib/Service/PollService.php +++ b/lib/Service/PollService.php @@ -411,9 +411,7 @@ public function clone(int $pollId): Poll { throw new ForbiddenException('Poll creation is disabled'); } - $this->poll = clone $origin; - - $this->poll->setOwner($this->userSession->getCurrentUserId()); + $this->poll = $origin->createClone($this->userSession->getCurrentUserId()); $this->poll = $this->pollMapper->insert($this->poll); $this->eventDispatcher->dispatchTyped(new PollCreatedEvent($this->poll)); diff --git a/tests/Unit/Service/OptionServiceTest.php b/tests/Unit/Service/OptionServiceTest.php index acf6e6e256..4a073cd237 100644 --- a/tests/Unit/Service/OptionServiceTest.php +++ b/tests/Unit/Service/OptionServiceTest.php @@ -9,7 +9,6 @@ namespace OCA\Polls\Tests\Unit\Service; use OCA\Polls\Db\Option; -use OCA\Polls\Db\OptionMapper; use OCA\Polls\Db\Poll; use OCA\Polls\Db\PollMapper; use OCA\Polls\Exceptions\InsufficientAttributesException; @@ -21,7 +20,6 @@ class OptionServiceTest extends UnitTestCase { private OptionService $optionService; - private OptionMapper $optionMapper; private PollMapper $pollMapper; private ISession $session; @@ -35,7 +33,6 @@ protected function setUp(): void { $this->session->set('ncPollsUserId', 'admin'); $this->optionService = Server::get(OptionService::class); - $this->optionMapper = Server::get(OptionMapper::class); $this->pollMapper = Server::get(PollMapper::class); // Text poll owned by admin, private, open @@ -66,11 +63,11 @@ protected function tearDown(): void { // Options are deleted when their poll is deleted try { $this->pollMapper->delete($this->textPoll); - } catch (\Exception $e) { + } catch (\Exception) { } try { $this->pollMapper->delete($this->datePoll); - } catch (\Exception $e) { + } catch (\Exception) { } } @@ -163,6 +160,10 @@ public function testConfirmOptionOnExpiredPoll(): void { $this->textPoll->setExpire(time() - 3600); $this->pollMapper->update($this->textPoll); + // Bust OptionService poll cache: load datePoll so the next call for textPoll + // causes a cache miss and re-reads the updated expire from DB + $this->optionService->list($this->datePoll->getId()); + $confirmed = $this->optionService->confirm($this->textOption->getId()); $this->assertGreaterThan(0, $confirmed->getConfirmed()); @@ -175,6 +176,9 @@ public function testConfirmTogglesConfirmation(): void { $this->textPoll->setExpire(time() - 3600); $this->pollMapper->update($this->textPoll); + // Bust OptionService poll cache so updated expire is read from DB + $this->optionService->list($this->datePoll->getId()); + $this->optionService->confirm($this->textOption->getId()); // confirm $unconfirmed = $this->optionService->confirm($this->textOption->getId()); // toggle back $this->assertSame(0, $unconfirmed->getConfirmed()); diff --git a/tests/Unit/Service/VoteServiceTest.php b/tests/Unit/Service/VoteServiceTest.php index 76b4bbc912..f9437263d3 100644 --- a/tests/Unit/Service/VoteServiceTest.php +++ b/tests/Unit/Service/VoteServiceTest.php @@ -9,13 +9,14 @@ namespace OCA\Polls\Tests\Unit\Service; use OCA\Polls\Db\Option; -use OCA\Polls\Db\OptionMapper; use OCA\Polls\Db\Poll; use OCA\Polls\Db\PollMapper; use OCA\Polls\Db\Share; use OCA\Polls\Db\ShareMapper; use OCA\Polls\Db\Vote; use OCA\Polls\Db\VoteMapper; +use OCA\Polls\Model\SimpleOption; +use OCA\Polls\Service\OptionService; use OCA\Polls\Service\VoteService; use OCA\Polls\Tests\Unit\UnitTestCase; use OCA\Polls\UserSession; @@ -24,8 +25,8 @@ class VoteServiceTest extends UnitTestCase { private VoteService $voteService; + private OptionService $optionService; private PollMapper $pollMapper; - private OptionMapper $optionMapper; private ShareMapper $shareMapper; private VoteMapper $voteMapper; private ISession $session; @@ -42,10 +43,10 @@ protected function setUp(): void { parent::setUp(); $this->session = Server::get(ISession::class); $this->pollMapper = Server::get(PollMapper::class); - $this->optionMapper = Server::get(OptionMapper::class); $this->shareMapper = Server::get(ShareMapper::class); $this->voteMapper = Server::get(VoteMapper::class); $this->voteService = Server::get(VoteService::class); + $this->optionService = Server::get(OptionService::class); $this->userSession = Server::get(UserSession::class); // Poll owned by admin, private access, voting open (useNo=0 is default) @@ -55,12 +56,6 @@ protected function setUp(): void { $poll->setExpire(0); $this->poll = $this->pollMapper->insert($poll); - // Option belonging to the poll - $option = $this->fm->instance('OCA\Polls\Db\Option'); - $option->setPollId($this->poll->getId()); - $option->setOwner('admin'); - $this->option = $this->optionMapper->insert($option); - // TYPE_USER share for admin — gives getCurrentUser() a User object (TYPE_USER) $ownerShare = $this->fm->instance('OCA\Polls\Db\Share'); $ownerShare->setPollId($this->poll->getId()); @@ -75,7 +70,16 @@ protected function setUp(): void { $externalShare->setUserId(self::EXTERNAL_USER_ID); $this->externalShare = $this->shareMapper->insert($externalShare); + // Login before creating option so OptionService can check permissions $this->loginAsOwner(); + + // Option created via service so pollOptionHash is computed and stored in DB + // (factory-created options bypass setPoll() and lack the hash, causing VoteMapper + // join failures) + $this->option = $this->optionService->add( + $this->poll->getId(), + (new SimpleOption())->setText('Test option') + ); } protected function tearDown(): void { From dbfd7e2a913b36d485707d0339b9c8e3d124f1ff Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 7 Mar 2026 12:02:41 +0100 Subject: [PATCH 13/20] add missing sortablejs Signed-off-by: dartcafe --- package-lock.json | 8 ++++++++ package.json | 1 + 2 files changed, 9 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4760f27c83..e389bcc8ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "marked-gfm-heading-id": "^4.1.3", "pinia": "^3.0.4", "qrcode": "^1.5.3", + "sortablejs": "^1.15.7", "vue": "^3.5.29", "vue-material-design-icons": "^5.3.0", "vue-router": "^4.6.4", @@ -11897,6 +11898,13 @@ "node": ">=10" } }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT", + "peer": true + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 4bd858cd40..269b70c06f 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "marked-gfm-heading-id": "^4.1.3", "pinia": "^3.0.4", "qrcode": "^1.5.3", + "sortablejs": "^1.15.7", "vue": "^3.5.29", "vue-material-design-icons": "^5.3.0", "vue-router": "^4.6.4", From 93e1243956a195c84cbe7f6ac40710deb6fc95e2 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 7 Mar 2026 12:18:38 +0100 Subject: [PATCH 14/20] fixing Signed-off-by: dartcafe --- lib/Db/Poll.php | 2 +- tests/Unit/Factories/PollFactory.php | 2 +- tests/Unit/Service/PollServiceTest.php | 2 +- tests/Unit/Service/VoteServiceTest.php | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/Db/Poll.php b/lib/Db/Poll.php index 84e6e88676..448f75df72 100644 --- a/lib/Db/Poll.php +++ b/lib/Db/Poll.php @@ -158,7 +158,7 @@ class Poll extends EntityWithUser implements JsonSerializable { protected int $adminAccess = 0; protected int $allowComment = 0; protected int $hideBookedUp = 0; - protected int $useNo = 0; + protected int $useNo = 1; protected int $lastInteraction = 0; protected ?string $miscSettings = ''; protected string $votingVariant = ''; diff --git a/tests/Unit/Factories/PollFactory.php b/tests/Unit/Factories/PollFactory.php index 1d17525310..5cb5cde98e 100644 --- a/tests/Unit/Factories/PollFactory.php +++ b/tests/Unit/Factories/PollFactory.php @@ -45,6 +45,6 @@ 'showResults' => Poll::SHOW_RESULTS_ALWAYS, 'adminAccess' => 0, 'hideBookedUp' => 0, - 'useNo' => 0, + 'useNo' => 1, 'misc_settings' => '', ]); diff --git a/tests/Unit/Service/PollServiceTest.php b/tests/Unit/Service/PollServiceTest.php index e0aa097b83..72af2f11c8 100644 --- a/tests/Unit/Service/PollServiceTest.php +++ b/tests/Unit/Service/PollServiceTest.php @@ -154,7 +154,7 @@ public function testCloneCreatesNewPoll(): void { $clone = $this->pollService->clone($this->poll->getId()); $this->assertInstanceOf(Poll::class, $clone); $this->assertNotSame($this->poll->getId(), $clone->getId()); - $this->assertSame($this->poll->getTitle(), $clone->getTitle()); + $this->assertSame('Clone of ' . $this->poll->getTitle(), $clone->getTitle()); $this->assertSame('admin', $clone->getOwner()); $this->pollMapper->delete($clone); } diff --git a/tests/Unit/Service/VoteServiceTest.php b/tests/Unit/Service/VoteServiceTest.php index f9437263d3..4cb69059d8 100644 --- a/tests/Unit/Service/VoteServiceTest.php +++ b/tests/Unit/Service/VoteServiceTest.php @@ -54,6 +54,7 @@ protected function setUp(): void { $poll->setOwner('admin'); $poll->setAccess(Poll::ACCESS_PRIVATE); $poll->setExpire(0); + $poll->setUseNo(0); $this->poll = $this->pollMapper->insert($poll); // TYPE_USER share for admin — gives getCurrentUser() a User object (TYPE_USER) From 9a12fa3255e4d67044ae7a6cc6dc463ee4898770 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 7 Mar 2026 13:10:22 +0100 Subject: [PATCH 15/20] more service tests Signed-off-by: dartcafe --- tests/Unit/Service/CommentServiceTest.php | 124 +++++++++++++++++ tests/Unit/Service/ShareServiceTest.php | 162 ++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 tests/Unit/Service/CommentServiceTest.php create mode 100644 tests/Unit/Service/ShareServiceTest.php diff --git a/tests/Unit/Service/CommentServiceTest.php b/tests/Unit/Service/CommentServiceTest.php new file mode 100644 index 0000000000..ac855c2898 --- /dev/null +++ b/tests/Unit/Service/CommentServiceTest.php @@ -0,0 +1,124 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->commentService = Server::get(CommentService::class); + $this->commentMapper = Server::get(CommentMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $this->poll = $this->pollMapper->insert($poll); + } + + protected function tearDown(): void { + parent::tearDown(); + // Comments cascade-delete with the poll + try { + $this->pollMapper->delete($this->poll); + } catch (\Exception) { + } + } + + // --- add --- + + public function testAddCreatesComment(): void { + $comment = $this->commentService->add('Hello World', $this->poll->getId()); + $this->assertInstanceOf(Comment::class, $comment); + $this->assertSame('Hello World', $comment->getComment()); + $this->assertSame('admin', $comment->getUserId()); + $this->assertSame($this->poll->getId(), $comment->getPollId()); + $this->assertSame(Comment::CONFIDENTIAL_NO, $comment->getConfidential()); + $this->assertNull($comment->getRecipient()); + } + + public function testAddConfidentialComment(): void { + $comment = $this->commentService->add('Secret', $this->poll->getId(), true); + $this->assertSame(Comment::CONFIDENTIAL_YES, $comment->getConfidential()); + // recipient is set to the poll owner for confidential comments + $this->assertSame('admin', $comment->getRecipient()); + } + + // --- list --- + + public function testListReturnsAddedComments(): void { + $this->commentService->add('First', $this->poll->getId()); + $this->commentService->add('Second', $this->poll->getId()); + $comments = $this->commentService->list($this->poll->getId()); + $this->assertCount(2, $comments); + } + + public function testListGroupsRapidCommentsFromSameUser(): void { + $first = $this->commentService->add('First', $this->poll->getId()); + $second = $this->commentService->add('Second', $this->poll->getId()); + $comments = $this->commentService->list($this->poll->getId()); + // Both posted within 5 minutes by the same user → second gets parent set + $this->assertSame(0, $comments[0]->getParent()); + $this->assertSame($first->getId(), $comments[1]->getParent()); + } + + public function testListDoesNotReturnDeletedComments(): void { + $comment = $this->commentService->add('To delete', $this->poll->getId()); + $this->commentService->delete($comment->getId()); + $comments = $this->commentService->list($this->poll->getId()); + $this->assertEmpty($comments); + } + + // --- delete / restore --- + + public function testDeleteSetsDeletedTimestamp(): void { + $comment = $this->commentService->add('To delete', $this->poll->getId()); + $deleted = $this->commentService->delete($comment->getId()); + $this->assertGreaterThan(0, $deleted->getDeleted()); + } + + public function testRestoreClearsDeletedTimestamp(): void { + $comment = $this->commentService->add('To restore', $this->poll->getId()); + $this->commentService->delete($comment->getId()); + $restored = $this->commentService->restore($comment->getId()); + $this->assertSame(0, $restored->getDeleted()); + } + + public function testOwnerCanDeleteForeignComment(): void { + // Insert a comment directly as a different user + $foreign = new Comment(); + $foreign->setPollId($this->poll->getId()); + $foreign->setUserId('other_user'); + $foreign->setComment('Foreign comment'); + $foreign->setTimestamp(time()); + $foreign->setConfidential(Comment::CONFIDENTIAL_NO); + $foreign = $this->commentMapper->insert($foreign); + + // Admin (poll owner) has PERMISSION_COMMENT_DELETE → can delete foreign comment + $deleted = $this->commentService->delete($foreign->getId()); + $this->assertGreaterThan(0, $deleted->getDeleted()); + } +} diff --git a/tests/Unit/Service/ShareServiceTest.php b/tests/Unit/Service/ShareServiceTest.php new file mode 100644 index 0000000000..f7757211ca --- /dev/null +++ b/tests/Unit/Service/ShareServiceTest.php @@ -0,0 +1,162 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->shareService = Server::get(ShareService::class); + $this->shareMapper = Server::get(ShareMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $this->poll = $this->pollMapper->insert($poll); + + // Pre-create a TYPE_USER share for admin (used in setType tests) + $share = $this->fm->instance('OCA\Polls\Db\Share'); + $share->setPollId($this->poll->getId()); + $share->setType(Share::TYPE_USER); + $share->setUserId('admin'); + $this->userShare = $this->shareMapper->insert($share); + } + + protected function tearDown(): void { + parent::tearDown(); + // Shares cascade-delete with the poll + try { + $this->pollMapper->delete($this->poll); + } catch (\Exception) { + } + } + + // --- add --- + + public function testAddPublicShareCreatesShare(): void { + $share = $this->shareService->add($this->poll->getId(), Share::TYPE_PUBLIC); + $this->assertInstanceOf(Share::class, $share); + $this->assertSame(Share::TYPE_PUBLIC, $share->getType()); + $this->assertSame($this->poll->getId(), $share->getPollId()); + $this->assertNotEmpty($share->getToken()); + } + + public function testAddEmailShareCreatesShare(): void { + $share = $this->shareService->add( + $this->poll->getId(), + Share::TYPE_EMAIL, + 'invited@polls.example.com', + 'Invited Person', + 'invited@polls.example.com', + ); + $this->assertInstanceOf(Share::class, $share); + $this->assertSame(Share::TYPE_EMAIL, $share->getType()); + $this->assertSame($this->poll->getId(), $share->getPollId()); + } + + public function testAddDuplicatePublicShareReturnsSameToken(): void { + $first = $this->shareService->add($this->poll->getId(), Share::TYPE_PUBLIC); + $second = $this->shareService->add($this->poll->getId(), Share::TYPE_PUBLIC); + // ShareAlreadyExistsException is caught internally; same share returned + $this->assertSame($first->getToken(), $second->getToken()); + } + + // --- get --- + + public function testGetShareByToken(): void { + $share = $this->shareService->get($this->userShare->getToken()); + $this->assertInstanceOf(Share::class, $share); + $this->assertSame($this->userShare->getId(), $share->getId()); + } + + // --- list --- + + public function testListReturnsPollShares(): void { + $shares = $this->shareService->list($this->poll->getId()); + $this->assertNotEmpty($shares); + foreach ($shares as $share) { + $this->assertSame($this->poll->getId(), $share->getPollId()); + } + } + + // --- delete / restore --- + + public function testDeleteByTokenSetsDeletedTimestamp(): void { + $deleted = $this->shareService->deleteByToken($this->userShare->getToken()); + $this->assertGreaterThan(0, $deleted->getDeleted()); + } + + public function testRestoreByTokenClearsDeletedTimestamp(): void { + $this->shareService->deleteByToken($this->userShare->getToken()); + $restored = $this->shareService->deleteByToken($this->userShare->getToken(), restore: true); + $this->assertSame(0, $restored->getDeleted()); + } + + // --- lock / unlock --- + + public function testLockByTokenSetsLockedTimestamp(): void { + $locked = $this->shareService->lockByToken($this->userShare->getToken()); + $this->assertGreaterThan(0, $locked->getLocked()); + } + + public function testUnlockByTokenClearsLockedTimestamp(): void { + $this->shareService->lockByToken($this->userShare->getToken()); + $unlocked = $this->shareService->lockByToken($this->userShare->getToken(), unlock: true); + $this->assertSame(0, $unlocked->getLocked()); + } + + // --- setType --- + + public function testSetTypeChangesUserShareToAdmin(): void { + $updated = $this->shareService->setType($this->userShare->getToken(), Share::TYPE_ADMIN); + $this->assertSame(Share::TYPE_ADMIN, $updated->getType()); + } + + public function testSetTypeChangesAdminShareToUser(): void { + // Promote to admin first, then demote back + $this->shareService->setType($this->userShare->getToken(), Share::TYPE_ADMIN); + $updated = $this->shareService->setType($this->userShare->getToken(), Share::TYPE_USER); + $this->assertSame(Share::TYPE_USER, $updated->getType()); + } + + public function testSetTypeRejectsInvalidTransition(): void { + // Only user↔admin transitions are supported; public→admin must not change + $publicShare = $this->shareService->add($this->poll->getId(), Share::TYPE_PUBLIC); + $result = $this->shareService->setType($publicShare->getToken(), Share::TYPE_ADMIN); + // setType silently returns the unchanged share when transition is not allowed + $this->assertSame(Share::TYPE_PUBLIC, $result->getType()); + } + + // --- setEmailAddress --- + + public function testSetEmailAddressThrowsForNonExternalShare(): void { + $this->expectException(InvalidShareTypeException::class); + $this->shareService->setEmailAddress($this->userShare, 'new@example.com'); + } +} From 8c9d7664065f7f7c6db4c15ec94a925074f8e630 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 7 Mar 2026 13:15:45 +0100 Subject: [PATCH 16/20] pretend logged in user Signed-off-by: dartcafe --- tests/Unit/Service/ShareServiceTest.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Service/ShareServiceTest.php b/tests/Unit/Service/ShareServiceTest.php index f7757211ca..e0274c475b 100644 --- a/tests/Unit/Service/ShareServiceTest.php +++ b/tests/Unit/Service/ShareServiceTest.php @@ -15,6 +15,7 @@ use OCA\Polls\Exceptions\InvalidShareTypeException; use OCA\Polls\Service\ShareService; use OCA\Polls\Tests\Unit\UnitTestCase; +use OCA\Polls\UserSession; use OCP\ISession; use OCP\Server; @@ -23,6 +24,7 @@ class ShareServiceTest extends UnitTestCase { private ShareMapper $shareMapper; private PollMapper $pollMapper; private ISession $session; + private UserSession $userSession; private Poll $poll; private Share $userShare; @@ -30,8 +32,7 @@ class ShareServiceTest extends UnitTestCase { protected function setUp(): void { parent::setUp(); $this->session = Server::get(ISession::class); - $this->session->set('ncPollsUserId', 'admin'); - + $this->userSession = Server::get(UserSession::class); $this->shareService = Server::get(ShareService::class); $this->shareMapper = Server::get(ShareMapper::class); $this->pollMapper = Server::get(PollMapper::class); @@ -40,12 +41,16 @@ protected function setUp(): void { $poll->setOwner('admin'); $this->poll = $this->pollMapper->insert($poll); - // Pre-create a TYPE_USER share for admin (used in setType tests) + // Pre-create a TYPE_USER share for admin. + // Also used to establish a proper logged-in session so that + // UserSession::getIsLoggedIn() returns true (required for PERMISSION_SHARE_ADD). $share = $this->fm->instance('OCA\Polls\Db\Share'); $share->setPollId($this->poll->getId()); $share->setType(Share::TYPE_USER); $share->setUserId('admin'); $this->userShare = $this->shareMapper->insert($share); + + $this->login(); } protected function tearDown(): void { @@ -57,6 +62,12 @@ protected function tearDown(): void { } } + private function login(): void { + $this->userSession->cleanSession(); + $this->session->set(UserSession::SESSION_KEY_SHARE_TOKEN, $this->userShare->getToken()); + $this->session->set(UserSession::SESSION_KEY_USER_ID, 'admin'); + } + // --- add --- public function testAddPublicShareCreatesShare(): void { From b738d7ebb7617c12d728fd8f5344695b23472bd4 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 7 Mar 2026 15:01:32 +0100 Subject: [PATCH 17/20] another approach Signed-off-by: dartcafe --- tests/Unit/Service/ShareServiceTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Unit/Service/ShareServiceTest.php b/tests/Unit/Service/ShareServiceTest.php index e0274c475b..fe4527752d 100644 --- a/tests/Unit/Service/ShareServiceTest.php +++ b/tests/Unit/Service/ShareServiceTest.php @@ -13,6 +13,7 @@ use OCA\Polls\Db\Share; use OCA\Polls\Db\ShareMapper; use OCA\Polls\Exceptions\InvalidShareTypeException; +use OCA\Polls\Model\Settings\SystemSettings; use OCA\Polls\Service\ShareService; use OCA\Polls\Tests\Unit\UnitTestCase; use OCA\Polls\UserSession; @@ -25,12 +26,23 @@ class ShareServiceTest extends UnitTestCase { private PollMapper $pollMapper; private ISession $session; private UserSession $userSession; + private SystemSettings $originalSystemSettings; private Poll $poll; private Share $userShare; protected function setUp(): void { parent::setUp(); + + // Save the real SystemSettings and replace it with a permissive mock so + // that PERMISSION_SHARE_ADD / PERMISSION_SHARE_ADD_EXTERNAL are not + // blocked by core IAppConfig group-restriction settings in the CI env. + $this->originalSystemSettings = Server::get(SystemSettings::class); + $settingsMock = $this->createMock(SystemSettings::class); + $settingsMock->method('getShareCreateAllowed')->willReturn(true); + $settingsMock->method('getExternalShareCreationAllowed')->willReturn(true); + \OC::$server->registerService(SystemSettings::class, fn () => $settingsMock); + $this->session = Server::get(ISession::class); $this->userSession = Server::get(UserSession::class); $this->shareService = Server::get(ShareService::class); @@ -60,10 +72,15 @@ protected function tearDown(): void { $this->pollMapper->delete($this->poll); } catch (\Exception) { } + // Restore the real SystemSettings so other test classes are unaffected + $original = $this->originalSystemSettings; + \OC::$server->registerService(SystemSettings::class, fn () => $original); } private function login(): void { $this->userSession->cleanSession(); + // Set the core Nextcloud user session so IUserSession::isLoggedIn() returns true + \OC_User::setUserId('admin'); $this->session->set(UserSession::SESSION_KEY_SHARE_TOKEN, $this->userShare->getToken()); $this->session->set(UserSession::SESSION_KEY_USER_ID, 'admin'); } From 13f0bad134fc2957d88a1e89a75375ef5e4d6c72 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 7 Mar 2026 15:07:15 +0100 Subject: [PATCH 18/20] fix Signed-off-by: dartcafe --- tests/Unit/Service/ShareServiceTest.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Service/ShareServiceTest.php b/tests/Unit/Service/ShareServiceTest.php index fe4527752d..57fbd770e5 100644 --- a/tests/Unit/Service/ShareServiceTest.php +++ b/tests/Unit/Service/ShareServiceTest.php @@ -108,11 +108,10 @@ public function testAddEmailShareCreatesShare(): void { $this->assertSame($this->poll->getId(), $share->getPollId()); } - public function testAddDuplicatePublicShareReturnsSameToken(): void { + public function testAddDuplicatePublicShareCreatesNewShare(): void { $first = $this->shareService->add($this->poll->getId(), Share::TYPE_PUBLIC); $second = $this->shareService->add($this->poll->getId(), Share::TYPE_PUBLIC); - // ShareAlreadyExistsException is caught internally; same share returned - $this->assertSame($first->getToken(), $second->getToken()); + $this->assertNotSame($first->getId(), $second->getId()); } // --- get --- From a6b644fea69a8120168561246524476c055ef299 Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 7 Mar 2026 15:16:26 +0100 Subject: [PATCH 19/20] more services Signed-off-by: dartcafe --- tests/Unit/Service/PollGroupServiceTest.php | 197 ++++++++++++++++++ tests/Unit/Service/PreferencesServiceTest.php | 74 +++++++ .../Unit/Service/SubscriptionServiceTest.php | 88 ++++++++ 3 files changed, 359 insertions(+) create mode 100644 tests/Unit/Service/PollGroupServiceTest.php create mode 100644 tests/Unit/Service/PreferencesServiceTest.php create mode 100644 tests/Unit/Service/SubscriptionServiceTest.php diff --git a/tests/Unit/Service/PollGroupServiceTest.php b/tests/Unit/Service/PollGroupServiceTest.php new file mode 100644 index 0000000000..1b22349fdb --- /dev/null +++ b/tests/Unit/Service/PollGroupServiceTest.php @@ -0,0 +1,197 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->pollGroupService = Server::get(PollGroupService::class); + $this->pollGroupMapper = Server::get(PollGroupMapper::class); + $this->pollMapper = Server::get(PollMapper::class); + + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $this->poll = $this->pollMapper->insert($poll); + } + + protected function tearDown(): void { + parent::tearDown(); + // Poll groups without polls are cleaned up by tidyPollGroups. + // The poll itself (and any linked group relations) must be deleted manually. + try { + $this->pollMapper->delete($this->poll); + } catch (\Exception) { + } + } + + // --- list --- + + public function testListPollGroupsReturnsArray(): void { + $groups = $this->pollGroupService->listPollGroups(); + $this->assertIsArray($groups); + } + + // --- addPollToPollGroup (new group by name) --- + + public function testAddPollToNewGroupCreatesGroup(): void { + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'TestGroup', + ); + $this->assertInstanceOf(PollGroup::class, $group); + $this->assertSame('TestGroup', $group->getName()); + $this->assertContains($this->poll->getId(), $group->getPollIds()); + } + + public function testAddPollToExistingGroup(): void { + // Create a group first via the service + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'ExistingGroup', + ); + + // Create a second poll and add it to the same group + $poll2 = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll2->setOwner('admin'); + $poll2 = $this->pollMapper->insert($poll2); + + try { + $updated = $this->pollGroupService->addPollToPollGroup( + $poll2->getId(), + pollGroupId: $group->getId(), + ); + $this->assertContains($poll2->getId(), $updated->getPollIds()); + } finally { + $this->pollMapper->delete($poll2); + } + } + + public function testAddPollWithoutGroupIdOrNameThrows(): void { + $this->expectException(\OCA\Polls\Exceptions\InsufficientAttributesException::class); + $this->pollGroupService->addPollToPollGroup($this->poll->getId()); + } + + // --- updatePollGroup --- + + public function testUpdatePollGroupChangesName(): void { + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'OriginalName', + ); + + $updated = $this->pollGroupService->updatePollGroup( + $group->getId(), + 'UpdatedName', + 'ext', + 'desc', + ); + + $this->assertSame('UpdatedName', $updated->getName()); + } + + public function testUpdatePollGroupThrowsForNonOwner(): void { + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'OwnedGroup', + ); + + // Switch session to a different user + $this->session->set('ncPollsUserId', 'other_user'); + + $this->expectException(ForbiddenException::class); + $this->pollGroupService->updatePollGroup( + $group->getId(), + 'HackedName', + '', + null, + ); + } + + // --- removePollFromPollGroup --- + + public function testRemovePollFromGroupLeavingOtherPollsReturnsGroup(): void { + // Add two polls to the same group + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'MultiPollGroup', + ); + + $poll2 = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll2->setOwner('admin'); + $poll2 = $this->pollMapper->insert($poll2); + + try { + $this->pollGroupService->addPollToPollGroup($poll2->getId(), pollGroupId: $group->getId()); + + // Remove only the first poll; group should survive + $this->session->set('ncPollsUserId', 'admin'); + $remaining = $this->pollGroupService->removePollFromPollGroup( + $this->poll->getId(), + $group->getId(), + ); + $this->assertInstanceOf(PollGroup::class, $remaining); + $this->assertNotContains($this->poll->getId(), $remaining->getPollIds()); + } finally { + $this->pollMapper->delete($poll2); + } + } + + public function testRemoveLastPollDeletesGroupAndReturnsNull(): void { + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'SinglePollGroup', + ); + + // Removing the only poll triggers tidyPollGroups → group is deleted → null returned + $result = $this->pollGroupService->removePollFromPollGroup( + $this->poll->getId(), + $group->getId(), + ); + $this->assertNull($result); + } + + public function testRemovePollNotInGroupThrows(): void { + $group = $this->pollGroupService->addPollToPollGroup( + $this->poll->getId(), + pollGroupName: 'GroupForNotFound', + ); + + $poll2 = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll2->setOwner('admin'); + $poll2 = $this->pollMapper->insert($poll2); + + try { + $this->expectException(NotFoundException::class); + $this->pollGroupService->removePollFromPollGroup($poll2->getId(), $group->getId()); + } finally { + $this->pollMapper->delete($poll2); + } + } +} diff --git a/tests/Unit/Service/PreferencesServiceTest.php b/tests/Unit/Service/PreferencesServiceTest.php new file mode 100644 index 0000000000..496ff40a94 --- /dev/null +++ b/tests/Unit/Service/PreferencesServiceTest.php @@ -0,0 +1,74 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + // PreferencesService is a singleton; call load() explicitly so it picks + // up the session user set above instead of whatever was set before. + $this->preferencesService = Server::get(PreferencesService::class); + $this->preferencesService->load(); + } + + // --- get --- + + public function testGetReturnsPreferences(): void { + $prefs = $this->preferencesService->get(); + $this->assertInstanceOf(Preferences::class, $prefs); + $this->assertSame('admin', $prefs->getUserId()); + } + + public function testGetReturnsDefaultSettings(): void { + $settings = $this->preferencesService->get()->getUserSettings(); + $this->assertArrayHasKey('useAlternativeStyling', $settings); + $this->assertArrayHasKey('defaultViewTextPoll', $settings); + } + + // --- write --- + + public function testWriteUpdatesSettings(): void { + $newSettings = array_merge( + Preferences::DEFAULT_SETTINGS, + ['useAlternativeStyling' => true], + ); + $prefs = $this->preferencesService->write($newSettings); + $this->assertInstanceOf(Preferences::class, $prefs); + $this->assertTrue($prefs->getUserSettings()['useAlternativeStyling']); + } + + public function testWriteDoesNotAffectOtherKeys(): void { + $newSettings = array_merge( + Preferences::DEFAULT_SETTINGS, + ['relevantOffset' => 60], + ); + $prefs = $this->preferencesService->write($newSettings); + $this->assertSame(60, $prefs->getUserSettings()['relevantOffset']); + // Other defaults remain intact + $this->assertArrayHasKey('defaultViewDatePoll', $prefs->getUserSettings()); + } + + public function testWriteUpdatesTimestamp(): void { + $before = time(); + $prefs = $this->preferencesService->write(Preferences::DEFAULT_SETTINGS); + $this->assertGreaterThanOrEqual($before, $prefs->getTimestamp()); + } +} diff --git a/tests/Unit/Service/SubscriptionServiceTest.php b/tests/Unit/Service/SubscriptionServiceTest.php new file mode 100644 index 0000000000..24ef19c2d5 --- /dev/null +++ b/tests/Unit/Service/SubscriptionServiceTest.php @@ -0,0 +1,88 @@ +session = Server::get(ISession::class); + $this->session->set('ncPollsUserId', 'admin'); + + $this->subscriptionService = Server::get(SubscriptionService::class); + $this->pollMapper = Server::get(PollMapper::class); + + $poll = $this->fm->instance('OCA\Polls\Db\Poll'); + $poll->setOwner('admin'); + $this->poll = $this->pollMapper->insert($poll); + } + + protected function tearDown(): void { + parent::tearDown(); + // Subscriptions cascade-delete with the poll + try { + $this->pollMapper->delete($this->poll); + } catch (\Exception) { + } + } + + // --- get --- + + public function testGetReturnsFalseWhenNotSubscribed(): void { + $this->assertFalse($this->subscriptionService->get($this->poll->getId())); + } + + public function testGetReturnsTrueWhenSubscribed(): void { + $this->subscriptionService->set(true, $this->poll->getId()); + $this->assertTrue($this->subscriptionService->get($this->poll->getId())); + } + + // --- set --- + + public function testSetSubscribeReturnsTrue(): void { + $result = $this->subscriptionService->set(true, $this->poll->getId()); + $this->assertTrue($result); + } + + public function testSetUnsubscribeWhenNotSubscribedReturnsFalse(): void { + $result = $this->subscriptionService->set(false, $this->poll->getId()); + $this->assertFalse($result); + } + + public function testSetUnsubscribeAfterSubscribeReturnsFalse(): void { + $this->subscriptionService->set(true, $this->poll->getId()); + $result = $this->subscriptionService->set(false, $this->poll->getId()); + $this->assertFalse($result); + } + + public function testSubscribeTwiceIsIdempotent(): void { + $this->subscriptionService->set(true, $this->poll->getId()); + // Duplicate insert hits unique constraint → returns true (already subscribed) + $result = $this->subscriptionService->set(true, $this->poll->getId()); + $this->assertTrue($result); + } + + public function testGetReturnsFalseAfterUnsubscribe(): void { + $this->subscriptionService->set(true, $this->poll->getId()); + $this->subscriptionService->set(false, $this->poll->getId()); + $this->assertFalse($this->subscriptionService->get($this->poll->getId())); + } +} From 366093cef3593e2a13e47e2d9507bf3c10d383bd Mon Sep 17 00:00:00 2001 From: dartcafe Date: Sat, 7 Mar 2026 18:03:32 +0100 Subject: [PATCH 20/20] fix Signed-off-by: dartcafe --- .../Unit/Service/SubscriptionServiceTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Unit/Service/SubscriptionServiceTest.php b/tests/Unit/Service/SubscriptionServiceTest.php index 24ef19c2d5..994102f360 100644 --- a/tests/Unit/Service/SubscriptionServiceTest.php +++ b/tests/Unit/Service/SubscriptionServiceTest.php @@ -12,18 +12,38 @@ use OCA\Polls\Db\PollMapper; use OCA\Polls\Service\SubscriptionService; use OCA\Polls\Tests\Unit\UnitTestCase; +use OCA\Polls\UserSession; use OCP\ISession; +use OCP\IUserManager; use OCP\Server; class SubscriptionServiceTest extends UnitTestCase { private SubscriptionService $subscriptionService; private PollMapper $pollMapper; private ISession $session; + private string $adminOriginalEmail = ''; private Poll $poll; protected function setUp(): void { parent::setUp(); + + // getAllowSubscribeToPoll() requires getCurrentUser()->getHasEmail(). + // Ensure admin has an email and that the core NC session is active so + // UserSession::getIsLoggedIn() returns true and getCurrentUser() resolves + // to the real admin user rather than Ghost. + $adminUser = Server::get(IUserManager::class)->get('admin'); + $this->adminOriginalEmail = $adminUser?->getEMailAddress() ?? ''; + if ($adminUser !== null && !$adminUser->getEMailAddress()) { + $adminUser->setEMailAddress('admin@test.local'); + } + \OC_User::setUserId('admin'); + + // Reset the polls UserSession cache so getCurrentUser() is re-resolved + // from the core session set above, then restore the polls session key. + $userSession = Server::get(UserSession::class); + $userSession->cleanSession(); + $this->session = Server::get(ISession::class); $this->session->set('ncPollsUserId', 'admin'); @@ -37,6 +57,7 @@ protected function setUp(): void { protected function tearDown(): void { parent::tearDown(); + Server::get(IUserManager::class)->get('admin')?->setEMailAddress($this->adminOriginalEmail); // Subscriptions cascade-delete with the poll try { $this->pollMapper->delete($this->poll);