dev #37
@@ -362,7 +362,27 @@ public class AdminMediaControllerService {
|
||||
}
|
||||
String extension = GENERATED_FORMAT_EXTENSIONS.get(format);
|
||||
Path outputFile = generatedDirectory.resolve(preset.name() + "." + extension);
|
||||
mediaFfmpegService.generateVariant(sourceFile, outputFile, dimensions.widthPx(), dimensions.heightPx(), format);
|
||||
try {
|
||||
mediaFfmpegService.generateVariant(
|
||||
sourceFile,
|
||||
outputFile,
|
||||
dimensions.widthPx(),
|
||||
dimensions.heightPx(),
|
||||
format
|
||||
);
|
||||
} catch (IOException e) {
|
||||
if (FORMAT_AVIF.equals(format)) {
|
||||
skippedFormats.add(format);
|
||||
logger.warn(
|
||||
"Skipping AVIF variant generation for asset {} preset '{}' because FFmpeg AVIF generation failed: {}",
|
||||
asset.getId(),
|
||||
preset.name(),
|
||||
e.getMessage()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
MediaVariant variant = new MediaVariant();
|
||||
variant.setMediaAsset(asset);
|
||||
|
||||
@@ -82,8 +82,6 @@ public class MediaFfmpegService {
|
||||
case "AVIF" -> {
|
||||
command.add("-c:v");
|
||||
command.add(encoder);
|
||||
command.add("-still-picture");
|
||||
command.add("1");
|
||||
command.add("-crf");
|
||||
command.add("30");
|
||||
command.add("-b:v");
|
||||
|
||||
@@ -51,6 +51,7 @@ import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -275,6 +276,31 @@ class AdminMediaControllerServiceTest {
|
||||
.noneMatch(variant -> "WEBP".equals(variant.getFormat()) || "AVIF".equals(variant.getFormat())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadAsset_whenAvifGenerationFails_shouldKeepAssetReadyAndStoreOtherVariants() throws Exception {
|
||||
when(mediaImageInspector.inspect(any(Path.class))).thenReturn(
|
||||
new MediaImageInspector.ImageMetadata("image/png", "png", 1600, 900)
|
||||
);
|
||||
doThrow(new java.io.IOException("FFmpeg failed to generate media variant. Unrecognized option 'still-picture'."))
|
||||
.when(mediaFfmpegService)
|
||||
.generateVariant(any(Path.class), any(Path.class), anyInt(), anyInt(), org.mockito.ArgumentMatchers.eq("AVIF"));
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"landing-hero.png",
|
||||
"image/png",
|
||||
"png-image-content".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
AdminMediaAssetDto dto = service.uploadAsset(file, " Landing hero ", " Main headline ", null);
|
||||
|
||||
assertEquals("READY", dto.getStatus());
|
||||
assertEquals(7, dto.getVariants().size());
|
||||
assertTrue(dto.getVariants().stream().noneMatch(variant -> "AVIF".equals(variant.getFormat())));
|
||||
assertEquals(3, dto.getVariants().stream().filter(variant -> "JPEG".equals(variant.getFormat())).count());
|
||||
assertEquals(3, dto.getVariants().stream().filter(variant -> "WEBP".equals(variant.getFormat())).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadAsset_withOversizedFile_shouldFailValidationBeforePersistence() {
|
||||
service = new AdminMediaControllerService(
|
||||
|
||||
@@ -6,8 +6,11 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class MediaFfmpegServiceTest {
|
||||
@@ -61,4 +64,57 @@ class MediaFfmpegServiceTest {
|
||||
|
||||
assertEquals("Media target file name must not start with '-'.", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateVariant_avifShouldNotUseStillPictureFlag() throws Exception {
|
||||
Path fakeFfmpeg = tempDir.resolve("fake-ffmpeg.sh");
|
||||
Files.writeString(
|
||||
fakeFfmpeg,
|
||||
"""
|
||||
#!/bin/sh
|
||||
if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then
|
||||
cat <<'EOF'
|
||||
V..... mjpeg
|
||||
V..... libwebp
|
||||
V..... libaom-av1
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "-still-picture" ]; then
|
||||
echo "Unrecognized option 'still-picture'. Error splitting the argument list: Option not found"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
last_arg=""
|
||||
for arg in "$@"; do
|
||||
last_arg="$arg"
|
||||
done
|
||||
|
||||
mkdir -p "$(dirname "$last_arg")"
|
||||
printf 'ok' > "$last_arg"
|
||||
exit 0
|
||||
"""
|
||||
);
|
||||
Files.setPosixFilePermissions(
|
||||
fakeFfmpeg,
|
||||
Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE
|
||||
)
|
||||
);
|
||||
|
||||
MediaFfmpegService service = new MediaFfmpegService(fakeFfmpeg.toString());
|
||||
Path source = tempDir.resolve("input.png");
|
||||
Path target = tempDir.resolve("output.avif");
|
||||
Files.writeString(source, "image");
|
||||
|
||||
service.generateVariant(source, target, 120, 80, "AVIF");
|
||||
|
||||
assertTrue(Files.exists(target));
|
||||
assertEquals("ok", Files.readString(target));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ export class SeoService {
|
||||
this.asString(mergedData['seoDescription']) ?? this.defaultDescription;
|
||||
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
|
||||
const ogTitle = this.asString(mergedData['ogTitle']) ?? title;
|
||||
const ogDescription = this.asString(mergedData['ogDescription']) ?? description;
|
||||
const ogDescription =
|
||||
this.asString(mergedData['ogDescription']) ?? description;
|
||||
|
||||
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,10 @@
|
||||
[ngModel]="orderTypeFilter"
|
||||
(ngModelChange)="onOrderTypeFilterChange($event)"
|
||||
>
|
||||
<option *ngFor="let option of orderTypeFilterOptions" [ngValue]="option">
|
||||
<option
|
||||
*ngFor="let option of orderTypeFilterOptions"
|
||||
[ngValue]="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -133,7 +136,9 @@
|
||||
<span
|
||||
class="order-type-badge"
|
||||
[class.order-type-badge--shop]="orderKind(selectedOrder) === 'SHOP'"
|
||||
[class.order-type-badge--mixed]="orderKind(selectedOrder) === 'MIXED'"
|
||||
[class.order-type-badge--mixed]="
|
||||
orderKind(selectedOrder) === 'MIXED'
|
||||
"
|
||||
>
|
||||
{{ orderKindLabel(selectedOrder) }}
|
||||
</span>
|
||||
@@ -162,7 +167,8 @@
|
||||
<strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span>
|
||||
</div>
|
||||
<div class="ui-meta-item">
|
||||
<strong>Tipo ordine</strong><span>{{ orderKindLabel(selectedOrder) }}</span>
|
||||
<strong>Tipo ordine</strong
|
||||
><span>{{ orderKindLabel(selectedOrder) }}</span>
|
||||
</div>
|
||||
<div class="ui-meta-item">
|
||||
<strong>Totale</strong
|
||||
@@ -279,7 +285,9 @@
|
||||
></span>
|
||||
<span>
|
||||
{{ getItemColorLabel(item) }}
|
||||
<ng-container *ngIf="getItemColorCodeSuffix(item) as colorCode">
|
||||
<ng-container
|
||||
*ngIf="getItemColorCodeSuffix(item) as colorCode"
|
||||
>
|
||||
({{ colorCode }})
|
||||
</ng-container>
|
||||
</span>
|
||||
@@ -300,7 +308,12 @@
|
||||
<button
|
||||
type="button"
|
||||
class="ui-button ui-button--ghost"
|
||||
(click)="downloadItemFile(item.id, item.originalFilename || itemDisplayName(item))"
|
||||
(click)="
|
||||
downloadItemFile(
|
||||
item.id,
|
||||
item.originalFilename || itemDisplayName(item)
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ downloadItemLabel(item) }}
|
||||
</button>
|
||||
@@ -373,7 +386,10 @@
|
||||
|
||||
<h4>Parametri per file</h4>
|
||||
<div class="file-color-list">
|
||||
<div class="file-color-row" *ngFor="let item of printItems(selectedOrder)">
|
||||
<div
|
||||
class="file-color-row"
|
||||
*ngFor="let item of printItems(selectedOrder)"
|
||||
>
|
||||
<span class="filename">{{ item.originalFilename }}</span>
|
||||
<span class="file-color">
|
||||
{{ getItemMaterialLabel(item) }} | Colore:
|
||||
|
||||
@@ -131,7 +131,8 @@ export class AdminDashboardComponent implements OnInit {
|
||||
this.selectedOrder = order;
|
||||
this.selectedStatus = order.status;
|
||||
this.selectedPaymentMethod = order.paymentMethod || 'OTHER';
|
||||
this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(order);
|
||||
this.showPrintDetails =
|
||||
this.showPrintDetails && this.hasPrintItems(order);
|
||||
this.detailLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
@@ -446,7 +447,8 @@ export class AdminDashboardComponent implements OnInit {
|
||||
this.selectedStatus = updatedOrder.status;
|
||||
this.selectedPaymentMethod =
|
||||
updatedOrder.paymentMethod || this.selectedPaymentMethod;
|
||||
this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(updatedOrder);
|
||||
this.showPrintDetails =
|
||||
this.showPrintDetails && this.hasPrintItems(updatedOrder);
|
||||
}
|
||||
|
||||
private applyListFiltersAndSelection(): void {
|
||||
|
||||
@@ -393,10 +393,7 @@ export class CheckoutComponent implements OnInit {
|
||||
}
|
||||
|
||||
private loadStlPreviews(session: any): void {
|
||||
if (
|
||||
!this.sessionId ||
|
||||
!Array.isArray(session?.items)
|
||||
) {
|
||||
if (!this.sessionId || !Array.isArray(session?.items)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,9 @@
|
||||
<app-button
|
||||
variant="outline"
|
||||
(click)="completeOrder()"
|
||||
[disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'"
|
||||
[disabled]="
|
||||
!selectedPaymentMethod || o.paymentStatus === 'REPORTED'
|
||||
"
|
||||
[fullWidth]="true"
|
||||
>
|
||||
{{
|
||||
@@ -201,10 +203,14 @@
|
||||
</div>
|
||||
|
||||
<div class="order-item-meta">
|
||||
<span>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span>
|
||||
<span
|
||||
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
|
||||
>
|
||||
<span *ngIf="showItemMaterial(item)">
|
||||
{{ "CHECKOUT.MATERIAL" | translate }}:
|
||||
{{ item.materialCode || ("ORDER.NOT_AVAILABLE" | translate) }}
|
||||
{{
|
||||
item.materialCode || ("ORDER.NOT_AVAILABLE" | translate)
|
||||
}}
|
||||
</span>
|
||||
<span *ngIf="itemVariantLabel(item) as variantLabel">
|
||||
{{ "SHOP.VARIANT" | translate }}: {{ variantLabel }}
|
||||
@@ -252,7 +258,9 @@
|
||||
<strong>{{ orderKindLabel(o) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="summary-label">{{ "ORDER.ITEM_COUNT" | translate }}</span>
|
||||
<span class="summary-label">{{
|
||||
"ORDER.ITEM_COUNT" | translate
|
||||
}}</span>
|
||||
<strong>{{ (o.items || []).length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -263,7 +263,9 @@ export class OrderComponent implements OnInit {
|
||||
return shopName;
|
||||
}
|
||||
|
||||
return String(item?.originalFilename ?? this.translate.instant('ORDER.NOT_AVAILABLE'));
|
||||
return String(
|
||||
item?.originalFilename ?? this.translate.instant('ORDER.NOT_AVAILABLE'),
|
||||
);
|
||||
}
|
||||
|
||||
itemVariantLabel(item: PublicOrderItem): string | null {
|
||||
|
||||
@@ -41,9 +41,7 @@
|
||||
<div class="pricing">
|
||||
<span class="price">{{ priceLabel() | currency: "CHF" }}</span>
|
||||
@if (hasPriceRange()) {
|
||||
<small class="price-note">{{
|
||||
"SHOP.PRICE_FROM" | translate
|
||||
}}</small>
|
||||
<small class="price-note">{{ "SHOP.PRICE_FROM" | translate }}</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@
|
||||
<button
|
||||
type="button"
|
||||
class="thumb"
|
||||
[class.active]="selectedImage().mediaAssetId === image.mediaAssetId"
|
||||
[class.active]="
|
||||
selectedImage().mediaAssetId === image.mediaAssetId
|
||||
"
|
||||
(click)="selectImage(image.mediaAssetId)"
|
||||
>
|
||||
@if (imageUrl(image); as imageUrl) {
|
||||
@@ -68,13 +70,16 @@
|
||||
</div>
|
||||
<div class="dimensions">
|
||||
<span>
|
||||
X {{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm
|
||||
X
|
||||
{{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm
|
||||
</span>
|
||||
<span>
|
||||
Y {{ p.model3d.boundingBoxYMm || 0 | number: "1.0-1" }} mm
|
||||
Y
|
||||
{{ p.model3d.boundingBoxYMm || 0 | number: "1.0-1" }} mm
|
||||
</span>
|
||||
<span>
|
||||
Z {{ p.model3d.boundingBoxZMm || 0 | number: "1.0-1" }} mm
|
||||
Z
|
||||
{{ p.model3d.boundingBoxZMm || 0 | number: "1.0-1" }} mm
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +133,8 @@
|
||||
<span class="cart-pill">
|
||||
{{
|
||||
"SHOP.IN_CART_LONG"
|
||||
| translate: { count: selectedVariantCartQuantity() }
|
||||
| translate
|
||||
: { count: selectedVariantCartQuantity() }
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
@@ -152,7 +158,9 @@
|
||||
<small>{{ variant.variantLabel }}</small>
|
||||
}
|
||||
</span>
|
||||
<strong>{{ variant.priceChf | currency: "CHF" }}</strong>
|
||||
<strong>{{
|
||||
variant.priceChf | currency: "CHF"
|
||||
}}</strong>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -160,9 +168,13 @@
|
||||
<div class="quantity-row">
|
||||
<span>{{ "SHOP.QUANTITY" | translate }}</span>
|
||||
<div class="qty-control">
|
||||
<button type="button" (click)="decreaseQuantity()">-</button>
|
||||
<button type="button" (click)="decreaseQuantity()">
|
||||
-
|
||||
</button>
|
||||
<span>{{ quantity() }}</span>
|
||||
<button type="button" (click)="increaseQuantity()">+</button>
|
||||
<button type="button" (click)="increaseQuantity()">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -173,9 +185,8 @@
|
||||
(click)="addToCart()"
|
||||
>
|
||||
{{
|
||||
(isAddingToCart()
|
||||
? "SHOP.ADDING"
|
||||
: "SHOP.ADD_CART") | translate
|
||||
(isAddingToCart() ? "SHOP.ADDING" : "SHOP.ADD_CART")
|
||||
| translate
|
||||
}}
|
||||
</app-button>
|
||||
|
||||
|
||||
@@ -121,7 +121,9 @@ export class ProductDetailComponent {
|
||||
|
||||
combineLatest([
|
||||
toObservable(this.productSlug, { injector: this.injector }),
|
||||
toObservable(this.languageService.currentLang, { injector: this.injector }),
|
||||
toObservable(this.languageService.currentLang, {
|
||||
injector: this.injector,
|
||||
}),
|
||||
])
|
||||
.pipe(
|
||||
tap(() => {
|
||||
@@ -160,13 +162,22 @@ export class ProductDetailComponent {
|
||||
}
|
||||
|
||||
this.product.set(product);
|
||||
this.selectedVariantId.set(product.defaultVariant?.id ?? product.variants[0]?.id ?? null);
|
||||
this.selectedImageAssetId.set(product.primaryImage?.mediaAssetId ?? product.images[0]?.mediaAssetId ?? null);
|
||||
this.selectedVariantId.set(
|
||||
product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
|
||||
);
|
||||
this.selectedImageAssetId.set(
|
||||
product.primaryImage?.mediaAssetId ??
|
||||
product.images[0]?.mediaAssetId ??
|
||||
null,
|
||||
);
|
||||
this.quantity.set(1);
|
||||
this.applySeo(product);
|
||||
|
||||
if (product.model3d?.url && product.model3d.originalFilename) {
|
||||
this.loadModelPreview(product.model3d.url, product.model3d.originalFilename);
|
||||
this.loadModelPreview(
|
||||
product.model3d.url,
|
||||
product.model3d.originalFilename,
|
||||
);
|
||||
} else {
|
||||
this.modelFile.set(null);
|
||||
this.modelLoading.set(false);
|
||||
@@ -239,7 +250,9 @@ export class ProductDetailComponent {
|
||||
}
|
||||
|
||||
priceLabel(): number {
|
||||
return this.selectedVariant()?.priceChf ?? this.product()?.priceFromChf ?? 0;
|
||||
return (
|
||||
this.selectedVariant()?.priceChf ?? this.product()?.priceFromChf ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
colorLabel(variant: ShopProductVariantOption): string {
|
||||
@@ -282,7 +295,8 @@ export class ProductDetailComponent {
|
||||
product.seoDescription ||
|
||||
product.excerpt ||
|
||||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||
const robots = product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
||||
const robots =
|
||||
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
||||
|
||||
this.seoService.applyPageSeo({
|
||||
title,
|
||||
|
||||
@@ -129,7 +129,9 @@ describe('ShopService', () => {
|
||||
it('posts add-to-cart with credentials and replaces local cart state', () => {
|
||||
service.addToCart('variant-red', 2).subscribe();
|
||||
|
||||
const request = httpMock.expectOne('http://localhost:8000/api/shop/cart/items');
|
||||
const request = httpMock.expectOne(
|
||||
'http://localhost:8000/api/shop/cart/items',
|
||||
);
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(request.request.withCredentials).toBeTrue();
|
||||
expect(request.request.body).toEqual({
|
||||
|
||||
@@ -251,9 +251,12 @@ export class ShopService {
|
||||
params = params.set('featured', String(featured));
|
||||
}
|
||||
|
||||
return this.http.get<ShopProductCatalogResponse>(`${this.apiUrl}/products`, {
|
||||
return this.http.get<ShopProductCatalogResponse>(
|
||||
`${this.apiUrl}/products`,
|
||||
{
|
||||
params,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getProduct(slug: string): Observable<ShopProductDetail> {
|
||||
@@ -337,10 +340,7 @@ export class ShopService {
|
||||
.pipe(tap((cart) => this.setCart(cart)));
|
||||
}
|
||||
|
||||
getProductModelFile(
|
||||
urlOrPath: string,
|
||||
filename: string,
|
||||
): Observable<File> {
|
||||
getProductModelFile(urlOrPath: string, filename: string): Observable<File> {
|
||||
return this.http
|
||||
.get(this.resolveApiUrl(urlOrPath), {
|
||||
responseType: 'blob',
|
||||
|
||||
Reference in New Issue
Block a user