View Javadoc
1   /*
2    * Copyright (C) 2023 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.configuration.dev;
7   
8   import static org.tailormap.api.persistence.Configuration.HOME_PAGE;
9   import static org.tailormap.api.persistence.Configuration.PORTAL_MENU;
10  import static org.tailormap.api.persistence.json.GeoServiceProtocol.QUANTIZEDMESH;
11  import static org.tailormap.api.persistence.json.GeoServiceProtocol.TILES3D;
12  import static org.tailormap.api.persistence.json.GeoServiceProtocol.WMS;
13  import static org.tailormap.api.persistence.json.GeoServiceProtocol.WMTS;
14  import static org.tailormap.api.persistence.json.GeoServiceProtocol.XYZ;
15  import static org.tailormap.api.persistence.json.HiddenLayerFunctionalityEnum.ATTRIBUTE_LIST;
16  import static org.tailormap.api.persistence.json.HiddenLayerFunctionalityEnum.EXPORT;
17  import static org.tailormap.api.persistence.json.HiddenLayerFunctionalityEnum.FEATURE_INFO;
18  import static org.tailormap.api.security.AuthorisationService.ACCESS_TYPE_VIEW;
19  
20  import com.fasterxml.jackson.core.JsonProcessingException;
21  import com.fasterxml.jackson.databind.ObjectMapper;
22  import java.io.IOException;
23  import java.lang.invoke.MethodHandles;
24  import java.nio.charset.StandardCharsets;
25  import java.time.OffsetDateTime;
26  import java.time.ZoneId;
27  import java.util.ArrayList;
28  import java.util.Collection;
29  import java.util.List;
30  import java.util.Map;
31  import java.util.NoSuchElementException;
32  import java.util.Optional;
33  import java.util.Properties;
34  import java.util.Set;
35  import java.util.UUID;
36  import org.apache.solr.client.solrj.SolrServerException;
37  import org.quartz.SchedulerException;
38  import org.slf4j.Logger;
39  import org.slf4j.LoggerFactory;
40  import org.springframework.beans.factory.annotation.Value;
41  import org.springframework.boot.SpringApplication;
42  import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
43  import org.springframework.boot.context.event.ApplicationReadyEvent;
44  import org.springframework.context.ApplicationContext;
45  import org.springframework.context.event.EventListener;
46  import org.springframework.core.io.ClassPathResource;
47  import org.springframework.jdbc.core.simple.JdbcClient;
48  import org.springframework.transaction.annotation.Transactional;
49  import org.springframework.util.PropertyPlaceholderHelper;
50  import org.tailormap.api.admin.model.TaskSchedule;
51  import org.tailormap.api.geotools.featuresources.FeatureSourceFactoryHelper;
52  import org.tailormap.api.geotools.featuresources.JDBCFeatureSourceHelper;
53  import org.tailormap.api.geotools.featuresources.WFSFeatureSourceHelper;
54  import org.tailormap.api.persistence.Application;
55  import org.tailormap.api.persistence.Catalog;
56  import org.tailormap.api.persistence.Configuration;
57  import org.tailormap.api.persistence.GeoService;
58  import org.tailormap.api.persistence.Group;
59  import org.tailormap.api.persistence.Page;
60  import org.tailormap.api.persistence.SearchIndex;
61  import org.tailormap.api.persistence.TMFeatureSource;
62  import org.tailormap.api.persistence.TMFeatureType;
63  import org.tailormap.api.persistence.Upload;
64  import org.tailormap.api.persistence.User;
65  import org.tailormap.api.persistence.helper.GeoServiceHelper;
66  import org.tailormap.api.persistence.json.AppContent;
67  import org.tailormap.api.persistence.json.AppLayerSettings;
68  import org.tailormap.api.persistence.json.AppSettings;
69  import org.tailormap.api.persistence.json.AppTreeLayerNode;
70  import org.tailormap.api.persistence.json.AppTreeLevelNode;
71  import org.tailormap.api.persistence.json.AppTreeNode;
72  import org.tailormap.api.persistence.json.AppUiSettings;
73  import org.tailormap.api.persistence.json.AttributeSettings;
74  import org.tailormap.api.persistence.json.AttributeValueSettings;
75  import org.tailormap.api.persistence.json.AuthorizationRule;
76  import org.tailormap.api.persistence.json.AuthorizationRuleDecision;
77  import org.tailormap.api.persistence.json.Bounds;
78  import org.tailormap.api.persistence.json.CatalogNode;
79  import org.tailormap.api.persistence.json.FeatureTypeRef;
80  import org.tailormap.api.persistence.json.FeatureTypeTemplate;
81  import org.tailormap.api.persistence.json.Filter;
82  import org.tailormap.api.persistence.json.FilterEditConfiguration;
83  import org.tailormap.api.persistence.json.FilterGroup;
84  import org.tailormap.api.persistence.json.GeoServiceDefaultLayerSettings;
85  import org.tailormap.api.persistence.json.GeoServiceLayerSettings;
86  import org.tailormap.api.persistence.json.GeoServiceSettings;
87  import org.tailormap.api.persistence.json.JDBCConnectionProperties;
88  import org.tailormap.api.persistence.json.MenuItem;
89  import org.tailormap.api.persistence.json.PageTile;
90  import org.tailormap.api.persistence.json.ServiceAuthentication;
91  import org.tailormap.api.persistence.json.TailormapObjectRef;
92  import org.tailormap.api.persistence.json.TileLayerHiDpiMode;
93  import org.tailormap.api.repository.ApplicationRepository;
94  import org.tailormap.api.repository.CatalogRepository;
95  import org.tailormap.api.repository.ConfigurationRepository;
96  import org.tailormap.api.repository.FeatureSourceRepository;
97  import org.tailormap.api.repository.GeoServiceRepository;
98  import org.tailormap.api.repository.GroupRepository;
99  import org.tailormap.api.repository.PageRepository;
100 import org.tailormap.api.repository.SearchIndexRepository;
101 import org.tailormap.api.repository.UploadRepository;
102 import org.tailormap.api.repository.UserRepository;
103 import org.tailormap.api.scheduling.IndexTask;
104 import org.tailormap.api.scheduling.TMJobDataMap;
105 import org.tailormap.api.scheduling.Task;
106 import org.tailormap.api.scheduling.TaskManagerService;
107 import org.tailormap.api.scheduling.TaskType;
108 import org.tailormap.api.security.InternalAdminAuthentication;
109 import org.tailormap.api.solr.SolrHelper;
110 import org.tailormap.api.solr.SolrService;
111 import org.tailormap.api.viewer.model.AppStyling;
112 import org.tailormap.api.viewer.model.Component;
113 import org.tailormap.api.viewer.model.ComponentConfig;
114 
115 /**
116  * Populates entities to add services and applications to demo functionality, support development and use in integration
117  * tests with a common set of test data. See README.md for usage details.
118  */
119 @org.springframework.context.annotation.Configuration
120 @ConditionalOnProperty(name = "tailormap-api.database.populate-testdata", havingValue = "true")
121 public class PopulateTestData {
122 
123   private static final Logger logger =
124       LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
125   private final ApplicationContext appContext;
126   private final UserRepository userRepository;
127   private final GroupRepository groupRepository;
128   private final CatalogRepository catalogRepository;
129   private final GeoServiceRepository geoServiceRepository;
130   private final GeoServiceHelper geoServiceHelper;
131   private final SolrService solrService;
132   private final TaskManagerService taskManagerService;
133   private final FeatureSourceRepository featureSourceRepository;
134   private final ApplicationRepository applicationRepository;
135   private final ConfigurationRepository configurationRepository;
136   private final SearchIndexRepository searchIndexRepository;
137   private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
138   private final UploadRepository uploadRepository;
139   private final PageRepository pageRepository;
140   private final JdbcClient jdbcClient;
141 
142   @Value("${spatial.dbs.connect:false}")
143   private boolean connectToSpatialDbs;
144 
145   @Value("#{'${tailormap-api.database.populate-testdata.categories}'.split(',')}")
146   private Set<String> categories;
147 
148   @Value("${spatial.dbs.localhost:true}")
149   private boolean connectToSpatialDbsAtLocalhost;
150 
151   @Value("${tailormap-api.database.populate-testdata.admin-hashed-password}")
152   private String adminHashedPassword;
153 
154   @Value("${tailormap-api.database.populate-testdata.exit:false}")
155   private boolean exit;
156 
157   @Value("${MAP5_URL:#{null}}")
158   private String map5url;
159 
160   private final String tiles3dProxyUsername = "b3p";
161 
162   // Password is for proxying a public service for testing purposes, not really secret
163   private final String tiles3dProxyPassword = "cOxPlJFWmtndFk0eVg5VdL";
164 
165   @Value("${tailormap-api.solr-batch-size:1000}")
166   private int solrBatchSize;
167 
168   @Value("${tailormap-api.solr-geometry-validation-rule:repairBuffer0}")
169   private String solrGeometryValidationRule;
170 
171   public PopulateTestData(
172       ApplicationContext appContext,
173       UserRepository userRepository,
174       GroupRepository groupRepository,
175       CatalogRepository catalogRepository,
176       GeoServiceRepository geoServiceRepository,
177       GeoServiceHelper geoServiceHelper,
178       SolrService solrService,
179       TaskManagerService taskManagerService,
180       FeatureSourceRepository featureSourceRepository,
181       ApplicationRepository applicationRepository,
182       ConfigurationRepository configurationRepository,
183       FeatureSourceFactoryHelper featureSourceFactoryHelper,
184       SearchIndexRepository searchIndexRepository,
185       UploadRepository uploadRepository,
186       PageRepository pageRepository,
187       JdbcClient jdbcClient) {
188     this.appContext = appContext;
189     this.userRepository = userRepository;
190     this.groupRepository = groupRepository;
191     this.catalogRepository = catalogRepository;
192     this.geoServiceRepository = geoServiceRepository;
193     this.geoServiceHelper = geoServiceHelper;
194     this.solrService = solrService;
195     this.taskManagerService = taskManagerService;
196     this.featureSourceRepository = featureSourceRepository;
197     this.applicationRepository = applicationRepository;
198     this.configurationRepository = configurationRepository;
199     this.featureSourceFactoryHelper = featureSourceFactoryHelper;
200     this.searchIndexRepository = searchIndexRepository;
201     this.uploadRepository = uploadRepository;
202     this.pageRepository = pageRepository;
203     this.jdbcClient = jdbcClient;
204   }
205 
206   @EventListener(ApplicationReadyEvent.class)
207   @Transactional
208   public void populate() throws Exception {
209     InternalAdminAuthentication.setInSecurityContext();
210     try {
211       // Used in conjunction with tailormap-api.database.clean=true so the database has been cleaned
212       // and the latest schema re-created
213       createTestUsersAndGroups();
214       createConfigurationTestData();
215       if (categories.contains("catalog")) {
216         createCatalogTestData();
217       }
218       if (categories.contains("apps")) {
219         createAppTestData();
220       }
221       if (categories.contains("search-index")) {
222         try {
223           createSolrIndex();
224         } catch (Exception e) {
225           logger.error("Exception creating Solr Index for testdata (continuing)", e);
226         }
227       }
228       if (categories.contains("search-index")) {
229         createSearchIndexTasks();
230       }
231       if (categories.contains("pages")) {
232         createPages();
233       }
234       logger.info("Test entities created");
235       if (categories.contains("drawing")) {
236         insertTestDrawing();
237         logger.info("Test drawing created");
238         try {
239           insertDrawingStyle();
240           logger.info("Test drawing style created");
241         } catch (Exception e) {
242           logger.error("Exception creating drawing style", e);
243         }
244       }
245     } finally {
246       InternalAdminAuthentication.clearSecurityContextAuthentication();
247     }
248     if (exit) {
249       // Exit after transaction is completed - for 'mvn verify' to populate testdata before
250       // integration tests
251       new Thread(() -> {
252             try {
253               logger.info("Exiting in 10 seconds");
254               Thread.sleep(10000);
255             } catch (InterruptedException ignored) {
256               // Ignore
257             }
258             SpringApplication.exit(appContext, () -> 0);
259             System.exit(0);
260           })
261           .start();
262     }
263   }
264 
265   public void createTestUsersAndGroups() throws NoSuchElementException {
266     Group groupFoo = new Group().setName("test-foo").setDescription("Used for integration tests.");
267     groupRepository.save(groupFoo);
268 
269     Group groupBar = new Group().setName("test-bar").setDescription("Used for integration tests.");
270     groupBar.addOrUpdateAdminProperty("group-property", true, true);
271     groupBar.addOrUpdateAdminProperty("group-private-property", 999.9, false);
272     groupRepository.save(groupBar);
273 
274     Group groupBaz = new Group().setName("test-baz").setDescription("Used for integration tests.");
275     groupRepository.save(groupBaz);
276 
277     // Normal user
278     User u = new User().setUsername("user").setPassword("{noop}user").setEmail("user@example.com");
279     u.getGroups().addAll(List.of(groupFoo, groupBar, groupBaz));
280     userRepository.save(u);
281 
282     // Foo only user
283     u = new User()
284         .setUsername("foo")
285         .setPassword("{noop}foo")
286         .setEmail("foo@example.com")
287         .setName("Foo only user");
288     u.getGroups().add(groupFoo);
289     userRepository.save(u);
290 
291     // Superuser with all access
292     u = new User().setUsername("tm-admin").setPassword(adminHashedPassword);
293     u.addOrUpdateAdminProperty("some-property", "some-value", true);
294     u.addOrUpdateAdminProperty("admin-property", "private-value", false);
295     u.getGroups().add(groupRepository.findById(Group.ADMIN).orElseThrow());
296     u.getGroups().add(groupBar);
297     userRepository.save(u);
298   }
299 
300   private final List<AuthorizationRule> ruleAnonymousRead = List.of(new AuthorizationRule()
301       .groupName(Group.ANONYMOUS)
302       .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)));
303 
304   private final List<AuthorizationRule> ruleLoggedIn = List.of(new AuthorizationRule()
305       .groupName(Group.AUTHENTICATED)
306       .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)));
307 
308   @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
309   private void createCatalogTestData() throws Exception {
310     Catalog catalog = catalogRepository.findById(Catalog.MAIN).orElseThrow();
311     CatalogNode rootCatalogNode = catalog.getNodes().get(0);
312     CatalogNode catalogNode = new CatalogNode().id("test").title("Test services");
313     rootCatalogNode.addChildrenItem(catalogNode.getId());
314     catalog.getNodes().add(catalogNode);
315 
316     String osmAttribution = "© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors";
317 
318     Bounds rdTileGridExtent =
319         new Bounds().minx(-285401.92).maxx(595401.92).miny(22598.08).maxy(903401.92);
320 
321     Upload legend = new Upload()
322         .setCategory(Upload.CATEGORY_LEGEND)
323         .setFilename("gemeentegebied-legend.png")
324         .setMimeType("image/png")
325         .setContent(new ClassPathResource("test/gemeentegebied-legend.png").getContentAsByteArray())
326         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
327     uploadRepository.save(legend);
328 
329     Collection<GeoService> services = List.of(
330         new GeoService()
331             .setId("demo")
332             .setProtocol(WMS)
333             .setTitle("Demo")
334             .setPublished(true)
335             .setAuthorizationRules(ruleAnonymousRead)
336             .setUrl("https://demo.tailormap.com/geoserver/geodata/ows?SERVICE=WMS"),
337         new GeoService()
338             .setId("osm")
339             .setProtocol(XYZ)
340             .setTitle("OSM")
341             .setUrl("https://tile.openstreetmap.org/{z}/{x}/{y}.png")
342             .setAuthorizationRules(ruleAnonymousRead)
343             .setSettings(new GeoServiceSettings()
344                 .xyzCrs("EPSG:3857")
345                 .layerSettings(Map.of(
346                     "xyz",
347                     new GeoServiceLayerSettings()
348                         .attribution(osmAttribution)
349                         .maxZoom(19)))),
350         // Layer settings configured later, using the same settings for this one and proxied one
351         new GeoService()
352             .setId("snapshot-geoserver")
353             .setProtocol(WMS)
354             .setTitle("Test GeoServer")
355             .setUrl("https://snapshot.tailormap.nl/geoserver/wms")
356             .setAuthorizationRules(ruleAnonymousRead)
357             .setPublished(true),
358         new GeoService()
359             .setId("filtered-snapshot-geoserver")
360             .setProtocol(WMS)
361             .setTitle("Test GeoServer (with authorization rules)")
362             .setUrl("https://snapshot.tailormap.nl/geoserver/wms")
363             .setAuthorizationRules(List.of(
364                 new AuthorizationRule()
365                     .groupName("test-foo")
366                     .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)),
367                 new AuthorizationRule()
368                     .groupName("test-baz")
369                     .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW))))
370             .setSettings(new GeoServiceSettings()
371                 .layerSettings(Map.of(
372                     "BGT",
373                     new GeoServiceLayerSettings()
374                         .addAuthorizationRulesItem(new AuthorizationRule()
375                             .groupName("test-foo")
376                             .decisions(Map.of(
377                                 ACCESS_TYPE_VIEW, AuthorizationRuleDecision.DENY)))
378                         .addAuthorizationRulesItem(new AuthorizationRule()
379                             .groupName("test-baz")
380                             .decisions(Map.of(
381                                 ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW))))))
382             .setPublished(true),
383         new GeoService()
384             .setId("snapshot-geoserver-proxied")
385             .setProtocol(WMS)
386             .setTitle("Test GeoServer (proxied)")
387             .setUrl("https://snapshot.tailormap.nl/geoserver/wms")
388             .setAuthorizationRules(ruleAnonymousRead)
389             .setSettings(new GeoServiceSettings().useProxy(true)),
390         new GeoService()
391             .setId("openbasiskaart")
392             .setProtocol(WMTS)
393             .setTitle("Openbasiskaart")
394             .setUrl("https://www.openbasiskaart.nl/mapcache/wmts")
395             .setAuthorizationRules(ruleAnonymousRead)
396             .setSettings(new GeoServiceSettings()
397                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings().attribution(osmAttribution))
398                 .layerSettings(Map.of(
399                     "osm",
400                     new GeoServiceLayerSettings()
401                         .title("Openbasiskaart")
402                         .hiDpiDisabled(false)
403                         .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERSHOWNEXTZOOMLEVEL)
404                         .hiDpiSubstituteLayer("osm-hq")))),
405         new GeoService()
406             .setId("openbasiskaart-proxied")
407             .setProtocol(WMTS)
408             .setTitle("Openbasiskaart (proxied)")
409             .setUrl("https://www.openbasiskaart.nl/mapcache/wmts")
410             .setAuthorizationRules(ruleAnonymousRead)
411             // The service actually doesn't require authentication, but also doesn't mind it
412             // Just for testing
413             .setAuthentication(new ServiceAuthentication()
414                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
415                 .username("test")
416                 .password("test"))
417             .setSettings(new GeoServiceSettings()
418                 .useProxy(true)
419                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings().attribution(osmAttribution))
420                 .layerSettings(Map.of(
421                     "osm",
422                     new GeoServiceLayerSettings()
423                         .hiDpiDisabled(false)
424                         .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERSHOWNEXTZOOMLEVEL)
425                         .hiDpiSubstituteLayer("osm-hq")))),
426         new GeoService()
427             .setId("openbasiskaart-tms")
428             .setProtocol(XYZ)
429             .setTitle("Openbasiskaart (TMS)")
430             .setUrl("https://openbasiskaart.nl/mapcache/tms/1.0.0/osm@rd/{z}/{x}/{-y}.png")
431             .setAuthorizationRules(ruleAnonymousRead)
432             .setSettings(
433                 new GeoServiceSettings()
434                     .xyzCrs("EPSG:28992")
435                     .defaultLayerSettings(
436                         new GeoServiceDefaultLayerSettings().attribution(osmAttribution))
437                     .layerSettings(
438                         Map.of(
439                             "xyz",
440                             new GeoServiceLayerSettings()
441                                 .maxZoom(15)
442                                 .tileGridExtent(rdTileGridExtent)
443                                 .hiDpiDisabled(false)
444                                 .hiDpiMode(
445                                     TileLayerHiDpiMode
446                                         .SUBSTITUTELAYERTILEPIXELRATIOONLY)
447                                 .hiDpiSubstituteLayer(
448                                     "https://openbasiskaart.nl/mapcache/tms/1.0.0/osm-hq@rd-hq/{z}/{x}/{-y}.png")))),
449         new GeoService()
450             .setId("pdok-hwh-luchtfotorgb")
451             .setProtocol(WMTS)
452             .setTitle("PDOK HWH luchtfoto")
453             .setUrl("https://service.pdok.nl/hwh/luchtfotorgb/wmts/v1_0")
454             .setAuthorizationRules(ruleAnonymousRead)
455             .setPublished(true)
456             .setSettings(new GeoServiceSettings()
457                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
458                     .attribution("© [Beeldmateriaal.nl](https://beeldmateriaal.nl)")
459                     .hiDpiDisabled(false))
460                 .putLayerSettingsItem(
461                     "Actueel_orthoHR", new GeoServiceLayerSettings().title("Luchtfoto"))),
462         new GeoService()
463             .setId("b3p-mapproxy-luchtfoto")
464             .setProtocol(XYZ)
465             .setTitle("Luchtfoto (TMS)")
466             .setUrl("https://mapproxy.b3p.nl/tms/1.0.0/luchtfoto/EPSG28992/{z}/{x}/{-y}.jpeg")
467             .setAuthorizationRules(ruleAnonymousRead)
468             .setPublished(true)
469             .setSettings(new GeoServiceSettings()
470                 .xyzCrs("EPSG:28992")
471                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
472                     .attribution("© [Beeldmateriaal.nl](https://beeldmateriaal.nl)")
473                     .hiDpiDisabled(false))
474                 .layerSettings(Map.of(
475                     "xyz",
476                     new GeoServiceLayerSettings()
477                         .maxZoom(14)
478                         .tileGridExtent(rdTileGridExtent)
479                         .hiDpiMode(TileLayerHiDpiMode.SHOWNEXTZOOMLEVEL)))),
480         new GeoService()
481             .setId("at-basemap")
482             .setProtocol(WMTS)
483             .setTitle("basemap.at")
484             .setUrl("https://mapsneu.wien.gv.at/basemapneu/1.0.0/WMTSCapabilities.xml")
485             .setAuthorizationRules(ruleAnonymousRead)
486             .setPublished(true)
487             .setSettings(new GeoServiceSettings()
488                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
489                     .attribution("© [basemap.at](https://basemap.at)")
490                     .hiDpiDisabled(true))
491                 .layerSettings(Map.of(
492                     "geolandbasemap",
493                     new GeoServiceLayerSettings()
494                         .title("Basemap")
495                         .hiDpiDisabled(false)
496                         .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERTILEPIXELRATIOONLY)
497                         .hiDpiSubstituteLayer("bmaphidpi"),
498                     "bmaporthofoto30cm",
499                     new GeoServiceLayerSettings()
500                         .title("Orthophoto")
501                         .hiDpiDisabled(false)))),
502         new GeoService()
503             .setId("pdok-kadaster-bestuurlijkegebieden")
504             .setProtocol(WMS)
505             .setUrl("https://service.pdok.nl/kadaster/bestuurlijkegebieden/wms/v1_0?service=WMS")
506             .setAuthorizationRules(ruleAnonymousRead)
507             .setSettings(new GeoServiceSettings()
508                 .defaultLayerSettings(new GeoServiceDefaultLayerSettings()
509                     .description("This layer shows an administrative boundary."))
510                 // No attribution required: service is CC0
511                 .serverType(GeoServiceSettings.ServerTypeEnum.MAPSERVER)
512                 .useProxy(true)
513                 .putLayerSettingsItem(
514                     "Gemeentegebied",
515                     new GeoServiceLayerSettings()
516                         .legendImageId(legend.getId().toString())))
517             .setPublished(true)
518             .setTitle("PDOK Kadaster bestuurlijke gebieden"),
519         new GeoService()
520             .setId("bestuurlijkegebieden-proxied")
521             .setProtocol(WMS)
522             .setUrl("https://service.pdok.nl/kadaster/bestuurlijkegebieden/wms/v1_0?service=WMS")
523             .setAuthorizationRules(ruleAnonymousRead)
524             // The service actually doesn't require authentication, but also doesn't mind it
525             // Just for testing that proxied services with auth are not available in public
526             // apps (even when logged in), in any controllers (map, proxy, features)
527             .setAuthentication(new ServiceAuthentication()
528                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
529                 .username("test")
530                 .password("test"))
531             .setSettings(new GeoServiceSettings()
532                 // No attribution required: service is CC0
533                 .serverType(GeoServiceSettings.ServerTypeEnum.MAPSERVER)
534                 .useProxy(true))
535             .setPublished(true)
536             .setTitle("Bestuurlijke gebieden (proxied met auth)"),
537         new GeoService()
538             .setId("3dbag_utrecht")
539             .setProtocol(TILES3D)
540             .setUrl("https://3dtilesnederland.nl/tiles/1.0/implicit/nederland/344.json")
541             .setTitle("3D BAG Utrecht")
542             .setPublished(true)
543             .setAuthorizationRules(ruleAnonymousRead),
544         new GeoService()
545             .setId("ahn_terrain_model")
546             .setProtocol(QUANTIZEDMESH)
547             .setUrl(
548                 "https://api.pdok.nl/kadaster/3d-basisvoorziening/ogc/v1/collections/digitaalterreinmodel")
549             .setTitle("AHN Terrain Model")
550             .setPublished(true)
551             .setAuthorizationRules(ruleAnonymousRead),
552         new GeoService()
553             .setId("3d_basisvoorziening_gebouwen_proxy")
554             .setProtocol(TILES3D)
555             .setUrl(
556                 "https://api.pdok.nl/kadaster/3d-basisvoorziening/ogc/v1_0/collections/gebouwen/3dtiles")
557             .setTitle("3D Basisvoorziening Gebouwen Proxy")
558             .setPublished(true)
559             .setAuthorizationRules(ruleAnonymousRead)
560             .setSettings(new GeoServiceSettings().useProxy(true)),
561         new GeoService()
562             .setId("3d_utrecht_proxied_auth")
563             .setProtocol(TILES3D)
564             .setUrl("https://snapshot.tailormap.nl/tiles3d-proxy/3dtiles")
565             .setTitle("3D Utrecht Proxied with Authorization")
566             .setPublished(true)
567             .setAuthorizationRules(ruleAnonymousRead)
568             .setAuthentication(new ServiceAuthentication()
569                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
570                 .username(tiles3dProxyUsername)
571                 .password(tiles3dProxyPassword))
572             .setSettings(new GeoServiceSettings().useProxy(true))
573         // TODO MapServer WMS "https://wms.geonorge.no/skwms1/wms.adm_enheter_historisk"
574         );
575 
576     if (map5url != null) {
577       GeoServiceLayerSettings osmAttr = new GeoServiceLayerSettings().attribution(osmAttribution);
578       GeoServiceLayerSettings map5Attr = new GeoServiceLayerSettings()
579           .attribution("Kaarten: [Map5.nl](https://map5.nl), data: " + osmAttribution);
580       services = new ArrayList<>(services);
581       services.add(new GeoService()
582           .setId("map5")
583           .setProtocol(WMTS)
584           .setTitle("Map5")
585           .setUrl(map5url)
586           .setAuthorizationRules(ruleAnonymousRead)
587           .setSettings(new GeoServiceSettings()
588               .defaultLayerSettings(new GeoServiceDefaultLayerSettings().hiDpiDisabled(true))
589               .layerSettings(Map.of(
590                   "openlufo",
591                   new GeoServiceLayerSettings()
592                       .attribution("© [Beeldmateriaal.nl](https://beeldmateriaal.nl), "
593                           + osmAttribution),
594                   "luforoadslabels",
595                   osmAttr,
596                   "map5topo",
597                   new GeoServiceLayerSettings()
598                       .attribution(map5Attr.getAttribution())
599                       .hiDpiDisabled(false)
600                       .hiDpiMode(TileLayerHiDpiMode.SUBSTITUTELAYERSHOWNEXTZOOMLEVEL)
601                       .hiDpiSubstituteLayer("map5topo_hq"),
602                   "map5topo_gray",
603                   map5Attr,
604                   "map5topo_simple",
605                   map5Attr,
606                   "map5topo_simple_gray",
607                   map5Attr,
608                   "opensimpletopo",
609                   osmAttr,
610                   "opensimpletopo_gray",
611                   osmAttr,
612                   "opentopo",
613                   osmAttr,
614                   "opentopo_gray",
615                   osmAttr))));
616     }
617 
618     for (GeoService geoService : services) {
619       try {
620         geoServiceHelper.loadServiceCapabilities(geoService);
621       } catch (Exception e) {
622         logger.error(
623             "Error loading capabilities for {} service URL {}: {}, {}",
624             geoService.getProtocol().getValue(),
625             geoService.getUrl(),
626             e.getClass(),
627             e.getMessage());
628       }
629 
630       geoServiceRepository.save(geoService);
631       catalogNode.addItemsItem(new TailormapObjectRef()
632           .kind(TailormapObjectRef.KindEnum.GEO_SERVICE)
633           .id(geoService.getId()));
634     }
635 
636     CatalogNode wfsFeatureSourceCatalogNode =
637         new CatalogNode().id("wfs_feature_sources").title("WFS feature sources");
638     rootCatalogNode.addChildrenItem(wfsFeatureSourceCatalogNode.getId());
639     catalog.getNodes().add(wfsFeatureSourceCatalogNode);
640 
641     services.stream().filter(s -> s.getProtocol() == WMS).forEach(s -> {
642       geoServiceHelper.findAndSaveRelatedWFS(s);
643       List<TMFeatureSource> linkedSources = featureSourceRepository.findByLinkedServiceId(s.getId());
644       for (TMFeatureSource linkedSource : linkedSources) {
645         wfsFeatureSourceCatalogNode.addItemsItem(new TailormapObjectRef()
646             .kind(TailormapObjectRef.KindEnum.FEATURE_SOURCE)
647             .id(linkedSource.getId().toString()));
648       }
649     });
650 
651     String geodataPassword = "980f1c8A-25933b2";
652 
653     Map<String, TMFeatureSource> featureSources = Map.of(
654         "postgis",
655         new TMFeatureSource()
656             .setProtocol(TMFeatureSource.Protocol.JDBC)
657             .setTitle("PostGIS")
658             .setJdbcConnection(new JDBCConnectionProperties()
659                 .dbtype(JDBCConnectionProperties.DbtypeEnum.POSTGIS)
660                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "postgis")
661                 .port(connectToSpatialDbsAtLocalhost ? 54322 : 5432)
662                 .database("geodata")
663                 .schema("public")
664                 .additionalProperties(Map.of("connectionOptions", "?ApplicationName=tailormap-api")))
665             .setAuthentication(new ServiceAuthentication()
666                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
667                 .username("geodata")
668                 .password(geodataPassword)),
669         "postgis_osm",
670         new TMFeatureSource()
671             .setProtocol(TMFeatureSource.Protocol.JDBC)
672             .setTitle("PostGIS OSM")
673             .setJdbcConnection(new JDBCConnectionProperties()
674                 .dbtype(JDBCConnectionProperties.DbtypeEnum.POSTGIS)
675                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "postgis")
676                 .port(connectToSpatialDbsAtLocalhost ? 54322 : 5432)
677                 .database("geodata")
678                 .schema("osm")
679                 .additionalProperties(Map.of("connectionOptions", "?ApplicationName=tailormap-api")))
680             .setAuthentication(new ServiceAuthentication()
681                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
682                 .username("geodata")
683                 .password(geodataPassword)),
684         "oracle",
685         new TMFeatureSource()
686             .setProtocol(TMFeatureSource.Protocol.JDBC)
687             .setTitle("Oracle")
688             .setJdbcConnection(new JDBCConnectionProperties()
689                 .dbtype(JDBCConnectionProperties.DbtypeEnum.ORACLE)
690                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "oracle")
691                 .database("/FREEPDB1")
692                 .schema("GEODATA")
693                 .additionalProperties(Map.of("connectionOptions", "?oracle.jdbc.J2EE13Compliant=true")))
694             .setAuthentication(new ServiceAuthentication()
695                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
696                 .username("geodata")
697                 .password(geodataPassword)),
698         "sqlserver",
699         new TMFeatureSource()
700             .setProtocol(TMFeatureSource.Protocol.JDBC)
701             .setTitle("MS SQL Server")
702             .setJdbcConnection(new JDBCConnectionProperties()
703                 .dbtype(JDBCConnectionProperties.DbtypeEnum.SQLSERVER)
704                 .host(connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "sqlserver")
705                 .database("geodata")
706                 .schema("dbo")
707                 .additionalProperties(Map.of("connectionOptions", ";encrypt=false")))
708             .setAuthentication(new ServiceAuthentication()
709                 .method(ServiceAuthentication.MethodEnum.PASSWORD)
710                 .username("geodata")
711                 .password(geodataPassword)),
712         "pdok-kadaster-bestuurlijkegebieden",
713         new TMFeatureSource()
714             .setProtocol(TMFeatureSource.Protocol.WFS)
715             .setUrl(
716                 "https://service.pdok.nl/kadaster/bestuurlijkegebieden/wfs/v1_0?service=WFS&VERSION=2.0.0")
717             .setTitle("Bestuurlijke gebieden")
718             .setNotes(
719                 "Overzicht van de bestuurlijke indeling van Nederland in gemeenten en provincies alsmede de rijksgrens. Gegevens zijn afgeleid uit de Basisregistratie Kadaster (BRK)."));
720     featureSourceRepository.saveAll(featureSources.values());
721 
722     new WFSFeatureSourceHelper().loadCapabilities(featureSources.get("pdok-kadaster-bestuurlijkegebieden"));
723     geoServiceRepository.findById("pdok-kadaster-bestuurlijkegebieden").ifPresent(geoService -> {
724       geoService
725           .getSettings()
726           .getLayerSettings()
727           .put(
728               "Provinciegebied",
729               new GeoServiceLayerSettings()
730                   .description("The administrative boundary of Dutch Provinces, connected to a WFS.")
731                   .featureType(new FeatureTypeRef()
732                       .featureSourceId(featureSources
733                           .get("pdok-kadaster-bestuurlijkegebieden")
734                           .getId())
735                       .featureTypeName("bestuurlijkegebieden:Provinciegebied"))
736                   .title("Provinciegebied (WFS)"));
737       geoServiceRepository.save(geoService);
738     });
739 
740     geoServiceRepository.findById("bestuurlijkegebieden-proxied").ifPresent(geoService -> {
741       geoService
742           .getSettings()
743           .getLayerSettings()
744           .put(
745               "Provinciegebied",
746               new GeoServiceLayerSettings()
747                   .featureType(new FeatureTypeRef()
748                       .featureSourceId(featureSources
749                           .get("pdok-kadaster-bestuurlijkegebieden")
750                           .getId())
751                       .featureTypeName("bestuurlijkegebieden:Provinciegebied"))
752                   .title("Provinciegebied (WFS, proxied met auth)"));
753       geoServiceRepository.save(geoService);
754     });
755 
756     CatalogNode featureSourceCatalogNode =
757         new CatalogNode().id("feature_sources").title("Test feature sources");
758     rootCatalogNode.addChildrenItem(featureSourceCatalogNode.getId());
759     catalog.getNodes().add(featureSourceCatalogNode);
760 
761     for (TMFeatureSource featureSource : featureSources.values()) {
762       featureSourceCatalogNode.addItemsItem(new TailormapObjectRef()
763           .kind(TailormapObjectRef.KindEnum.FEATURE_SOURCE)
764           .id(featureSource.getId().toString()));
765     }
766     catalogRepository.save(catalog);
767 
768     if (connectToSpatialDbs) {
769       featureSources.values().forEach(fs -> {
770         try {
771           if (fs.getProtocol() == TMFeatureSource.Protocol.JDBC) {
772             new JDBCFeatureSourceHelper().loadCapabilities(fs);
773           } else if (fs.getProtocol() == TMFeatureSource.Protocol.WFS) {
774             new WFSFeatureSourceHelper().loadCapabilities(fs);
775           }
776         } catch (Exception e) {
777           logger.error("Error loading capabilities for feature source {}", fs.getTitle(), e);
778         }
779       });
780 
781       services.stream()
782           // Set layer settings for both the proxied and non-proxied one, but don't overwrite the
783           // authorization rules for the "filtered-snapshot-geoserver" service
784           .filter(s -> s.getId().startsWith("snapshot-geoserver"))
785           .forEach(s -> s.getSettings()
786               .layerSettings(Map.of(
787                   "postgis:begroeidterreindeel",
788                   new GeoServiceLayerSettings()
789                       .description(
790                           """
791 This layer shows data from https://www.postgis.net/
792 
793 https://postgis.net/brand.svg""")
794                       .featureType(new FeatureTypeRef()
795                           .featureSourceId(featureSources
796                               .get("postgis")
797                               .getId())
798                           .featureTypeName("begroeidterreindeel")),
799                   "postgis:bak",
800                   new GeoServiceLayerSettings()
801                       .featureType(new FeatureTypeRef()
802                           .featureSourceId(featureSources
803                               .get("postgis")
804                               .getId())
805                           .featureTypeName("bak")),
806                   "postgis:kadastraal_perceel",
807                   new GeoServiceLayerSettings()
808                       .description("cadastral parcel label points")
809                       .featureType(new FeatureTypeRef()
810                           .featureSourceId(featureSources
811                               .get("postgis")
812                               .getId())
813                           .featureTypeName("kadastraal_perceel")),
814                   "sqlserver:wegdeel",
815                   new GeoServiceLayerSettings()
816                       .attribution(
817                           "CC BY 4.0 [BGT/Kadaster](https://www.nationaalgeoregister.nl/geonetwork/srv/api/records/2cb4769c-b56e-48fa-8685-c48f61b9a319)")
818                       .description(
819                           """
820 This layer shows data from [MS SQL Server](https://learn.microsoft.com/en-us/sql/relational-databases/spatial/spatial-data-sql-server).
821 
822 https://social.technet.microsoft.com/wiki/cfs-filesystemfile.ashx/__key/communityserver-components-imagefileviewer/communityserver-wikis-components-files-00-00-00-00-05/1884.SQL_5F00_h_5F00_rgb.png_2D00_550x0.png""")
823                       .featureType(new FeatureTypeRef()
824                           .featureSourceId(featureSources
825                               .get("sqlserver")
826                               .getId())
827                           .featureTypeName("wegdeel")),
828                   "oracle:WATERDEEL",
829                   new GeoServiceLayerSettings()
830                       .description("This layer shows data from Oracle Spatial.")
831                       .featureType(new FeatureTypeRef()
832                           .featureSourceId(featureSources
833                               .get("oracle")
834                               .getId())
835                           .featureTypeName("WATERDEEL")),
836                   "postgis:osm_polygon",
837                   new GeoServiceLayerSettings()
838                       .description("This layer shows OSM data from postgis.")
839                       .featureType(new FeatureTypeRef()
840                           .featureSourceId(featureSources
841                               .get("postgis_osm")
842                               .getId())
843                           .featureTypeName("osm_polygon")))));
844     }
845 
846     featureSources.get("pdok-kadaster-bestuurlijkegebieden").getFeatureTypes().stream()
847         .filter(ft -> ft.getName().equals("bestuurlijkegebieden:Provinciegebied"))
848         .findFirst()
849         .ifPresent(ft -> {
850           ft.getSettings().addHideAttributesItem("identificatie");
851           ft.getSettings().addHideAttributesItem("ligtInLandCode");
852           ft.getSettings().addHideAttributesItem("fuuid");
853           ft.getSettings().putAttributeSettingsItem("naam", new AttributeSettings().title("Naam"));
854           ft.getSettings()
855               .setTemplate(
856                   new FeatureTypeTemplate()
857                       .templateLanguage("simple")
858                       .markupLanguage("markdown")
859                       .template(
860                           """
861 ### Provincie
862 Deze provincie heet **{{naam}}** en ligt in _{{ligtInLandNaam}}_.
863 
864 | Attribuut | Waarde             |
865 | --------- | ------------------ |
866 | `code`    | {{code}}           |
867 | `naam`    | {{naam}}           |
868 | `ligt in` | {{ligtInLandNaam}} |"""));
869         });
870 
871     featureSources.get("postgis").getFeatureTypes().stream()
872         .filter(ft -> ft.getName().equals("begroeidterreindeel"))
873         .findFirst()
874         .ifPresent(ft -> {
875           ft.getSettings().addHideAttributesItem("terminationdate");
876           ft.getSettings().addHideAttributesItem("geom_kruinlijn");
877           ft.getSettings().putAttributeSettingsItem("gmlid", new AttributeSettings().title("GML ID"));
878           ft.getSettings()
879               .putAttributeSettingsItem("identificatie", new AttributeSettings().title("Identificatie"));
880           ft.getSettings()
881               .putAttributeSettingsItem(
882                   "tijdstipregistratie", new AttributeSettings().title("Registratie"));
883           ft.getSettings()
884               .putAttributeSettingsItem(
885                   "eindregistratie", new AttributeSettings().title("Eind registratie"));
886           ft.getSettings().putAttributeSettingsItem("class", new AttributeSettings().title("Klasse"));
887           ft.getSettings()
888               .putAttributeSettingsItem("bronhouder", new AttributeSettings().title("Bronhouder"));
889           ft.getSettings()
890               .putAttributeSettingsItem("inonderzoek", new AttributeSettings().title("In onderzoek"));
891           ft.getSettings()
892               .putAttributeSettingsItem(
893                   "relatievehoogteligging", new AttributeSettings().title("Relatieve hoogteligging"));
894           ft.getSettings()
895               .putAttributeSettingsItem("bgt_status", new AttributeSettings().title("BGT status"));
896           ft.getSettings()
897               .putAttributeSettingsItem("plus_status", new AttributeSettings().title("Plus-status"));
898           ft.getSettings()
899               .putAttributeSettingsItem(
900                   "plus_fysiekvoorkomen", new AttributeSettings().title("Plus-fysiek voorkomen"));
901           ft.getSettings()
902               .putAttributeSettingsItem(
903                   "begroeidterreindeeloptalud", new AttributeSettings().title("Op talud"));
904           ft.getSettings().addAttributeOrderItem("identificatie");
905           ft.getSettings().addAttributeOrderItem("bronhouder");
906           ft.getSettings().addAttributeOrderItem("class");
907         });
908 
909     featureSources.get("postgis").getFeatureTypes().stream()
910         .filter(ft -> ft.getName().equals("bak"))
911         .findFirst()
912         .ifPresent(ft -> {
913           ft.getSettings().addHideAttributesItem("gmlid");
914           ft.getSettings().addHideAttributesItem("lv_publicatiedatum");
915           ft.getSettings().addHideAttributesItem("creationdate");
916           ft.getSettings().addHideAttributesItem("tijdstipregistratie");
917           ft.getSettings().addHideAttributesItem("eindregistratie");
918           ft.getSettings().addHideAttributesItem("terminationdate");
919           ft.getSettings().addHideAttributesItem("inonderzoek");
920           ft.getSettings().addHideAttributesItem("relatievehoogteligging");
921           ft.getSettings().addHideAttributesItem("bgt_status");
922           ft.getSettings().addHideAttributesItem("plus_status");
923           ft.getSettings().addHideAttributesItem("function_");
924           ft.getSettings().addHideAttributesItem("plus_type");
925         });
926 
927     featureSources.get("postgis").getFeatureTypes().stream()
928         .filter(ft -> ft.getName().equals("kadastraal_perceel"))
929         .findFirst()
930         .ifPresent(ft -> ft.getSettings().addHideAttributesItem("gml_id"));
931   }
932 
933   public void createAppTestData() throws Exception {
934     Upload logo = new Upload()
935         .setCategory(Upload.CATEGORY_APP_LOGO)
936         .setFilename("gradient.svg")
937         .setMimeType("image/svg+xml")
938         .setContent(new ClassPathResource("test/gradient-logo.svg").getContentAsByteArray())
939         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
940     uploadRepository.save(logo);
941 
942     List<AppTreeNode> baseNodes = List.of(
943         new AppTreeLayerNode()
944             .objectType("AppTreeLayerNode")
945             .id("lyr:openbasiskaart:osm")
946             .serviceId("openbasiskaart")
947             .layerName("osm")
948             .visible(true),
949         new AppTreeLayerNode()
950             .objectType("AppTreeLayerNode")
951             .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR")
952             .serviceId("pdok-hwh-luchtfotorgb")
953             .layerName("Actueel_orthoHR")
954             .visible(false));
955 
956     Application app = new Application()
957         .setName("default")
958         .setTitle("Tailormap demo")
959         .setCrs("EPSG:28992")
960         .setAuthorizationRules(ruleAnonymousRead)
961         .setComponents(List.of(
962             new Component()
963                 .type("SIMPLE_SEARCH")
964                 .config(new ComponentConfig()
965                     .enabled(true)
966                     .putAdditionalProperty("municipalities", List.of("0344"))),
967             new Component().type("EDIT").config(new ComponentConfig().enabled(true)),
968             new Component()
969                 .type("COORDINATE_LINK_WINDOW")
970                 .config(new ComponentConfig()
971                     .enabled(true)
972                     .putAdditionalProperty(
973                         "urls",
974                         List.of(
975                             Map.of(
976                                 "id",
977                                 "google-maps",
978                                 "url",
979                                 "https://www.google.com/maps/@[lat],[lon],18z",
980                                 "alias",
981                                 "Google Maps",
982                                 "projection",
983                                 "EPSG:4326"),
984                             Map.of(
985                                 "id",
986                                 "tm-demo",
987                                 "url",
988                                 "https://demo.tailormap.com/#@[X],[Y],18",
989                                 "alias",
990                                 "Tailormap demo",
991                                 "projection",
992                                 "EPSG:28992"))))))
993         .setContentRoot(new AppContent()
994             .addBaseLayerNodesItem(new AppTreeLevelNode()
995                 .objectType("AppTreeLevelNode")
996                 .id("root-base-layers")
997                 .root(true)
998                 .title("Base layers")
999                 .childrenIds(List.of(
1000                     "lyr:openbasiskaart:osm",
1001                     "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR",
1002                     "lyr:openbasiskaart-proxied:osm",
1003                     "lyr:openbasiskaart-tms:xyz",
1004                     "lyr:b3p-mapproxy-luchtfoto:xyz")))
1005             .addBaseLayerNodesItem(
1006                 // This layer from a secured proxied service should not be proxyable in a
1007                 // public app, see test_wms_secured_proxy_not_in_public_app() testcase
1008                 new AppTreeLayerNode()
1009                     .objectType("AppTreeLayerNode")
1010                     .id("lyr:openbasiskaart-proxied:osm")
1011                     .serviceId("openbasiskaart-proxied")
1012                     .layerName("osm")
1013                     .visible(false))
1014             .addBaseLayerNodesItem(new AppTreeLayerNode()
1015                 .objectType("AppTreeLayerNode")
1016                 .id("lyr:openbasiskaart-tms:xyz")
1017                 .serviceId("openbasiskaart-tms")
1018                 .layerName("xyz")
1019                 .visible(false))
1020             .addBaseLayerNodesItem(new AppTreeLayerNode()
1021                 .objectType("AppTreeLayerNode")
1022                 .id("lyr:b3p-mapproxy-luchtfoto:xyz")
1023                 .serviceId("b3p-mapproxy-luchtfoto")
1024                 .layerName("xyz")
1025                 .visible(false))
1026             .addLayerNodesItem(new AppTreeLevelNode()
1027                 .objectType("AppTreeLevelNode")
1028                 .id("root")
1029                 .root(true)
1030                 .title("Layers")
1031                 .childrenIds(List.of(
1032                     "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1033                     "lyr:bestuurlijkegebieden-proxied:Provinciegebied",
1034                     "lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied",
1035                     "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1036                     "lyr:snapshot-geoserver:postgis:bak",
1037                     "lyr:snapshot-geoserver:postgis:kadastraal_perceel",
1038                     "lyr:snapshot-geoserver:sqlserver:wegdeel",
1039                     "lyr:snapshot-geoserver:oracle:WATERDEEL",
1040                     "lyr:snapshot-geoserver:BGT",
1041                     "lvl:proxied",
1042                     "lvl:osm",
1043                     "lvl:archeo")))
1044             .addLayerNodesItem(new AppTreeLayerNode()
1045                 .objectType("AppTreeLayerNode")
1046                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied")
1047                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1048                 .layerName("Provinciegebied")
1049                 .visible(true))
1050             // This is a layer from proxied service with auth that should also not be
1051             // visible, but it has a feature source attached, should also be denied for
1052             // features access and not be included in TOC
1053             .addLayerNodesItem(new AppTreeLayerNode()
1054                 .objectType("AppTreeLayerNode")
1055                 .id("lyr:bestuurlijkegebieden-proxied:Provinciegebied")
1056                 .serviceId("bestuurlijkegebieden-proxied")
1057                 .layerName("Provinciegebied")
1058                 .visible(false))
1059             .addLayerNodesItem(new AppTreeLayerNode()
1060                 .objectType("AppTreeLayerNode")
1061                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied")
1062                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1063                 .layerName("Gemeentegebied")
1064                 .visible(true))
1065             .addLayerNodesItem(new AppTreeLayerNode()
1066                 .objectType("AppTreeLayerNode")
1067                 .id("lyr:snapshot-geoserver:postgis:begroeidterreindeel")
1068                 .serviceId("snapshot-geoserver")
1069                 .layerName("postgis:begroeidterreindeel")
1070                 .visible(true))
1071             .addLayerNodesItem(new AppTreeLayerNode()
1072                 .objectType("AppTreeLayerNode")
1073                 .id("lyr:snapshot-geoserver:postgis:bak")
1074                 .serviceId("snapshot-geoserver")
1075                 .layerName("postgis:bak")
1076                 .visible(false))
1077             .addLayerNodesItem(new AppTreeLayerNode()
1078                 .objectType("AppTreeLayerNode")
1079                 .id("lyr:snapshot-geoserver:postgis:kadastraal_perceel")
1080                 .serviceId("snapshot-geoserver")
1081                 .layerName("postgis:kadastraal_perceel")
1082                 .visible(false))
1083             .addLayerNodesItem(new AppTreeLayerNode()
1084                 .objectType("AppTreeLayerNode")
1085                 .id("lyr:snapshot-geoserver:sqlserver:wegdeel")
1086                 .serviceId("snapshot-geoserver")
1087                 .layerName("sqlserver:wegdeel")
1088                 .visible(true))
1089             .addLayerNodesItem(new AppTreeLayerNode()
1090                 .objectType("AppTreeLayerNode")
1091                 .id("lyr:snapshot-geoserver:oracle:WATERDEEL")
1092                 .serviceId("snapshot-geoserver")
1093                 .layerName("oracle:WATERDEEL")
1094                 .visible(true))
1095             .addLayerNodesItem(new AppTreeLayerNode()
1096                 .objectType("AppTreeLayerNode")
1097                 .id("lyr:snapshot-geoserver:BGT")
1098                 .serviceId("snapshot-geoserver")
1099                 .layerName("BGT")
1100                 .visible(false))
1101             .addLayerNodesItem(new AppTreeLevelNode()
1102                 .objectType("AppTreeLevelNode")
1103                 .id("lvl:proxied")
1104                 .title("Proxied")
1105                 .childrenIds(List.of("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")))
1106             .addLayerNodesItem(new AppTreeLayerNode()
1107                 .objectType("AppTreeLayerNode")
1108                 .id("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")
1109                 .serviceId("snapshot-geoserver-proxied")
1110                 .layerName("postgis:begroeidterreindeel")
1111                 .visible(false))
1112             .addLayerNodesItem(new AppTreeLevelNode()
1113                 .objectType("AppTreeLevelNode")
1114                 .id("lvl:osm")
1115                 .title("OSM")
1116                 .childrenIds(List.of("lyr:snapshot-geoserver:postgis:osm_polygon")))
1117             .addLayerNodesItem(new AppTreeLayerNode()
1118                 .objectType("AppTreeLayerNode")
1119                 .id("lyr:snapshot-geoserver:postgis:osm_polygon")
1120                 .serviceId("snapshot-geoserver")
1121                 .layerName("postgis:osm_polygon")
1122                 .visible(false))
1123             .addLayerNodesItem(new AppTreeLevelNode()
1124                 .objectType("AppTreeLevelNode")
1125                 .id("lvl:archeo")
1126                 .title("Archeology")
1127                 .childrenIds(List.of("lyr:demo:geomorfologie")))
1128             .addLayerNodesItem(new AppTreeLayerNode()
1129                 .objectType("AppTreeLayerNode")
1130                 .id("lyr:demo:geomorfologie")
1131                 .serviceId("demo")
1132                 .layerName("geomorfologie")
1133                 .visible(true)))
1134         .setStyling(new AppStyling().logo(logo.getId().toString()))
1135         .setSettings(new AppSettings()
1136             .putLayerSettingsItem("lyr:openbasiskaart:osm", new AppLayerSettings().title("Openbasiskaart"))
1137             .putLayerSettingsItem(
1138                 "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR", new AppLayerSettings().title("Luchtfoto"))
1139             .putLayerSettingsItem(
1140                 "lyr:openbasiskaart-proxied:osm",
1141                 new AppLayerSettings().title("Openbasiskaart (proxied)"))
1142             .putLayerSettingsItem(
1143                 "lyr:snapshot-geoserver:oracle:WATERDEEL",
1144                 new AppLayerSettings()
1145                     .opacity(50)
1146                     .title("Waterdeel overridden title")
1147                     .editable(true)
1148                     .description("This is the layer description from the app layer setting.")
1149                     .attribution(
1150                         "CC BY 4.0 [BGT/Kadaster](https://www.nationaalgeoregister.nl/geonetwork/srv/api/records/2cb4769c-b56e-48fa-8685-c48f61b9a319)"))
1151             .putLayerSettingsItem(
1152                 "lyr:snapshot-geoserver:postgis:osm_polygon",
1153                 new AppLayerSettings()
1154                     .description("OpenStreetMap polygon data in EPSG:3857")
1155                     .opacity(60)
1156                     .editable(true)
1157                     .title("OSM Polygon (EPSG:3857)")
1158                     .attribution(
1159                         "© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors"))
1160             .putLayerSettingsItem(
1161                 "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1162                 new AppLayerSettings()
1163                     .editable(true)
1164                     .addHideAttributesItem("begroeidterreindeeloptalud")
1165                     .addReadOnlyAttributesItem("eindregistratie"))
1166             .putLayerSettingsItem(
1167                 "lyr:snapshot-geoserver:postgis:kadastraal_perceel",
1168                 new AppLayerSettings().editable(true).addReadOnlyAttributesItem("aanduiding"))
1169             .putLayerSettingsItem(
1170                 "lyr:snapshot-geoserver:sqlserver:wegdeel", new AppLayerSettings().editable(true))
1171             .putLayerSettingsItem(
1172                 "lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel",
1173                 new AppLayerSettings().editable(false))
1174             .putLayerSettingsItem(
1175                 "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1176                 new AppLayerSettings()
1177                     .hiddenFunctionality(Set.of(FEATURE_INFO, ATTRIBUTE_LIST, EXPORT)))
1178             .addFilterGroupsItem(new FilterGroup()
1179                 .id("filtergroup1")
1180                 .source("PRESET")
1181                 .type(FilterGroup.TypeEnum.ATTRIBUTE)
1182                 .layerIds(List.of("lyr:snapshot-geoserver:postgis:begroeidterreindeel"))
1183                 .operator(FilterGroup.OperatorEnum.AND)
1184                 .addFiltersItem(new Filter()
1185                     .id("filter1")
1186                     .type(Filter.TypeEnum.ATTRIBUTE)
1187                     .condition(Filter.ConditionEnum.BEFORE)
1188                     .addValueItem("2025-06-05")
1189                     .attribute("creationdate")
1190                     .attributeType(Filter.AttributeTypeEnum.DATE))
1191                 .addFiltersItem(new Filter()
1192                     .id("filter2")
1193                     .type(Filter.TypeEnum.ATTRIBUTE)
1194                     .condition(Filter.ConditionEnum.UNIQUE_VALUES)
1195                     .addValueItem("bodembedekkers")
1196                     .addValueItem("bosplantsoen")
1197                     .addValueItem("gras- en kruidachtigen")
1198                     .attribute("plus_fysiekvoorkomen")
1199                     .attributeType(Filter.AttributeTypeEnum.STRING)
1200                     .editConfiguration(new FilterEditConfiguration()
1201                         .filterTool(FilterEditConfiguration.FilterToolEnum.CHECKBOX)
1202                         .attributeValuesSettings(List.of(
1203                             new AttributeValueSettings()
1204                                 .value("bodembedekkers")
1205                                 .initiallySelected(true)
1206                                 .selectable(true)
1207                                 .alias("Bodembedekkers"),
1208                             new AttributeValueSettings()
1209                                 .value("bosplantsoen")
1210                                 .initiallySelected(true)
1211                                 .selectable(true)
1212                                 .alias("Bosplantsoen"),
1213                             new AttributeValueSettings()
1214                                 .value("gras- en kruidachtigen")
1215                                 .initiallySelected(true)
1216                                 .selectable(true)
1217                                 .alias("Gras- en kruidachtigen"),
1218                             new AttributeValueSettings()
1219                                 .value("griend en hakhout")
1220                                 .initiallySelected(false)
1221                                 .selectable(true),
1222                             new AttributeValueSettings()
1223                                 .value("heesters")
1224                                 .initiallySelected(false)
1225                                 .selectable(true),
1226                             new AttributeValueSettings()
1227                                 .value("planten")
1228                                 .initiallySelected(false)
1229                                 .selectable(true),
1230                             new AttributeValueSettings()
1231                                 .value("struikrozen")
1232                                 .initiallySelected(false)
1233                                 .selectable(true),
1234                             new AttributeValueSettings()
1235                                 .value("waardeOnbekend")
1236                                 .initiallySelected(false)
1237                                 .selectable(true))))))
1238             .addFilterGroupsItem(new FilterGroup()
1239                 .id("filtergroup2")
1240                 .source("PRESET")
1241                 .type(FilterGroup.TypeEnum.ATTRIBUTE)
1242                 .layerIds(List.of("lyr:snapshot-geoserver:postgis:kadastraal_perceel"))
1243                 .operator(FilterGroup.OperatorEnum.AND)
1244                 .addFiltersItem(new Filter()
1245                     .id("filter3")
1246                     .type(Filter.TypeEnum.ATTRIBUTE)
1247                     .condition(Filter.ConditionEnum.u)
1248                     .addValueItem("1")
1249                     .addValueItem("12419")
1250                     .attribute("perceelnummer")
1251                     .attributeType(Filter.AttributeTypeEnum.DOUBLE)
1252                     .editConfiguration(new FilterEditConfiguration()
1253                         .filterTool(FilterEditConfiguration.FilterToolEnum.SLIDER)
1254                         .initialLowerValue(1d)
1255                         .initialUpperValue(12419d)
1256                         .minimumValue(1d)
1257                         .maximumValue(12419d)))));
1258 
1259     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1260     app.setInitialExtent(
1261         new Bounds().minx(130011d).miny(458031d).maxx(132703d).maxy(459995d));
1262     app.setMaxExtent(new Bounds().minx(-285401d).miny(22598d).maxx(595401d).maxy(903401d));
1263 
1264     if (map5url != null) {
1265       AppTreeLevelNode root =
1266           (AppTreeLevelNode) app.getContentRoot().getBaseLayerNodes().get(0);
1267       List<String> childrenIds = new ArrayList<>(root.getChildrenIds());
1268       childrenIds.add("lyr:map5:map5topo");
1269       childrenIds.add("lyr:map5:map5topo_simple");
1270       childrenIds.add("lvl:luchtfoto-labels");
1271       root.setChildrenIds(childrenIds);
1272       app.getSettings()
1273           .putLayerSettingsItem("lyr:map5:map5topo", new AppLayerSettings().title("Map5"))
1274           .putLayerSettingsItem("lyr:map5:map5topo_simple", new AppLayerSettings().title("Map5 simple"));
1275       app.getContentRoot()
1276           .addBaseLayerNodesItem(new AppTreeLayerNode()
1277               .objectType("AppTreeLayerNode")
1278               .id("lyr:map5:map5topo")
1279               .serviceId("map5")
1280               .layerName("map5topo")
1281               .visible(false))
1282           .addBaseLayerNodesItem(new AppTreeLayerNode()
1283               .objectType("AppTreeLayerNode")
1284               .id("lyr:map5:map5topo_simple")
1285               .serviceId("map5")
1286               .layerName("map5topo_simple")
1287               .visible(false))
1288           .addBaseLayerNodesItem(new AppTreeLevelNode()
1289               .objectType("AppTreeLevelNode")
1290               .id("lvl:luchtfoto-labels")
1291               .title("Luchtfoto met labels")
1292               .addChildrenIdsItem("lyr:map5:luforoadslabels")
1293               .addChildrenIdsItem("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR2"))
1294           .addBaseLayerNodesItem(new AppTreeLayerNode()
1295               .objectType("AppTreeLayerNode")
1296               .id("lyr:map5:luforoadslabels")
1297               .serviceId("map5")
1298               .layerName("luforoadslabels")
1299               .visible(false))
1300           .addBaseLayerNodesItem(new AppTreeLayerNode()
1301               .objectType("AppTreeLayerNode")
1302               .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR2")
1303               .serviceId("pdok-hwh-luchtfotorgb")
1304               .layerName("Actueel_orthoHR")
1305               .visible(false));
1306     }
1307 
1308     applicationRepository.save(app);
1309 
1310     app = new Application()
1311         .setName("base")
1312         .setTitle("Service base app")
1313         .setCrs("EPSG:28992")
1314         .setAuthorizationRules(ruleAnonymousRead)
1315         .setContentRoot(new AppContent()
1316             .addBaseLayerNodesItem(new AppTreeLevelNode()
1317                 .objectType("AppTreeLevelNode")
1318                 .id("root-base-layers")
1319                 .root(true)
1320                 .title("Base layers")
1321                 .childrenIds(List.of(
1322                     "lyr:openbasiskaart:osm", "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR"))));
1323     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1324     applicationRepository.save(app);
1325 
1326     app = new Application()
1327         .setName("secured")
1328         .setTitle("secured app")
1329         .setCrs("EPSG:28992")
1330         .setAuthorizationRules(ruleLoggedIn)
1331         .setContentRoot(new AppContent()
1332             .addBaseLayerNodesItem(new AppTreeLevelNode()
1333                 .objectType("AppTreeLevelNode")
1334                 .id("root-base-layers")
1335                 .root(true)
1336                 .title("Base layers")
1337                 .childrenIds(List.of(
1338                     "lyr:openbasiskaart:osm",
1339                     "lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR",
1340                     "lyr:openbasiskaart-proxied:osm")))
1341             .addBaseLayerNodesItem(new AppTreeLayerNode()
1342                 .objectType("AppTreeLayerNode")
1343                 .id("lyr:openbasiskaart-proxied:osm")
1344                 .serviceId("openbasiskaart-proxied")
1345                 .layerName("osm")
1346                 .visible(false))
1347             .addLayerNodesItem(new AppTreeLevelNode()
1348                 .objectType("AppTreeLevelNode")
1349                 .id("root")
1350                 .root(true)
1351                 .title("Layers")
1352                 .childrenIds(List.of(
1353                     "lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied",
1354                     "lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied",
1355                     "lvl:proxied")))
1356             .addLayerNodesItem(new AppTreeLayerNode()
1357                 .objectType("AppTreeLayerNode")
1358                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Gemeentegebied")
1359                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1360                 .layerName("Gemeentegebied")
1361                 .visible(true))
1362             .addLayerNodesItem(new AppTreeLayerNode()
1363                 .objectType("AppTreeLayerNode")
1364                 .id("lyr:pdok-kadaster-bestuurlijkegebieden:Provinciegebied")
1365                 .serviceId("pdok-kadaster-bestuurlijkegebieden")
1366                 .layerName("Provinciegebied")
1367                 .visible(false))
1368             .addLayerNodesItem(new AppTreeLevelNode()
1369                 .objectType("AppTreeLevelNode")
1370                 .id("lvl:proxied")
1371                 .title("Proxied")
1372                 .childrenIds(List.of("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")))
1373             .addLayerNodesItem(new AppTreeLayerNode()
1374                 .objectType("AppTreeLayerNode")
1375                 .id("lyr:snapshot-geoserver-proxied:postgis:begroeidterreindeel")
1376                 .serviceId("snapshot-geoserver-proxied")
1377                 .layerName("postgis:begroeidterreindeel")
1378                 .visible(false)))
1379         .setSettings(new AppSettings()
1380             .putLayerSettingsItem(
1381                 "lyr:openbasiskaart-proxied:osm",
1382                 new AppLayerSettings().title("Openbasiskaart (proxied)")));
1383 
1384     app.getContentRoot().getBaseLayerNodes().addAll(baseNodes);
1385     applicationRepository.save(app);
1386 
1387     app = new Application()
1388         .setName("secured-auth")
1389         .setTitle("secured (with authorizations)")
1390         .setCrs("EPSG:28992")
1391         .setAuthorizationRules(List.of(
1392             new AuthorizationRule()
1393                 .groupName("test-foo")
1394                 .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW)),
1395             new AuthorizationRule()
1396                 .groupName("test-bar")
1397                 .decisions(Map.of(ACCESS_TYPE_VIEW, AuthorizationRuleDecision.ALLOW))))
1398         .setContentRoot(new AppContent()
1399             .addLayerNodesItem(new AppTreeLevelNode()
1400                 .objectType("AppTreeLevelNode")
1401                 .id("root")
1402                 .root(true)
1403                 .title("Layers")
1404                 .childrenIds(List.of("lvl:needs-auth", "lvl:public")))
1405             .addLayerNodesItem(new AppTreeLevelNode()
1406                 .objectType("AppTreeLevelNode")
1407                 .id("lvl:public")
1408                 .title("Public")
1409                 .childrenIds(List.of("lyr:snapshot-geoserver:BGT")))
1410             .addLayerNodesItem(new AppTreeLevelNode()
1411                 .objectType("AppTreeLevelNode")
1412                 .id("lvl:needs-auth")
1413                 .title("Needs auth")
1414                 .childrenIds(List.of(
1415                     "lyr:filtered-snapshot-geoserver:BGT",
1416                     "lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")))
1417             .addLayerNodesItem(new AppTreeLayerNode()
1418                 .objectType("AppTreeLayerNode")
1419                 .id("lyr:filtered-snapshot-geoserver:BGT")
1420                 .serviceId("filtered-snapshot-geoserver")
1421                 .layerName("BGT")
1422                 .visible(true))
1423             .addLayerNodesItem(new AppTreeLayerNode()
1424                 .objectType("AppTreeLayerNode")
1425                 .id("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")
1426                 .serviceId("filtered-snapshot-geoserver")
1427                 .layerName("postgis:begroeidterreindeel")
1428                 .visible(true))
1429             .addLayerNodesItem(new AppTreeLayerNode()
1430                 .objectType("AppTreeLayerNode")
1431                 .id("lyr:snapshot-geoserver:BGT")
1432                 .serviceId("snapshot-geoserver")
1433                 .layerName("BGT")
1434                 .visible(true)));
1435 
1436     applicationRepository.save(app);
1437 
1438     app = new Application()
1439         .setName("austria")
1440         .setCrs("EPSG:3857")
1441         .setAuthorizationRules(ruleAnonymousRead)
1442         .setTitle("Austria")
1443         .setInitialExtent(
1444             new Bounds().minx(987982d).miny(5799551d).maxx(1963423d).maxy(6320708d))
1445         .setMaxExtent(
1446             new Bounds().minx(206516d).miny(5095461d).maxx(3146930d).maxy(7096232d))
1447         .setContentRoot(new AppContent()
1448             .addBaseLayerNodesItem(new AppTreeLevelNode()
1449                 .objectType("AppTreeLevelNode")
1450                 .id("root-base-layers")
1451                 .root(true)
1452                 .title("Base layers")
1453                 .childrenIds(List.of(
1454                     "lyr:at-basemap:geolandbasemap",
1455                     "lyr:at-basemap:orthofoto",
1456                     "lvl:orthofoto-labels",
1457                     "lyr:osm:xyz")))
1458             .addBaseLayerNodesItem(new AppTreeLayerNode()
1459                 .objectType("AppTreeLayerNode")
1460                 .id("lyr:at-basemap:geolandbasemap")
1461                 .serviceId("at-basemap")
1462                 .layerName("geolandbasemap")
1463                 .visible(true))
1464             .addBaseLayerNodesItem(new AppTreeLayerNode()
1465                 .objectType("AppTreeLayerNode")
1466                 .id("lyr:at-basemap:orthofoto")
1467                 .serviceId("at-basemap")
1468                 .layerName("bmaporthofoto30cm")
1469                 .visible(false))
1470             .addBaseLayerNodesItem(new AppTreeLevelNode()
1471                 .objectType("AppTreeLevelNode")
1472                 .id("lvl:orthofoto-labels")
1473                 .title("Orthophoto with labels")
1474                 .childrenIds(List.of("lyr:at-basemap:bmapoverlay", "lyr:at-basemap:orthofoto_2")))
1475             .addBaseLayerNodesItem(new AppTreeLayerNode()
1476                 .objectType("AppTreeLayerNode")
1477                 .id("lyr:at-basemap:bmapoverlay")
1478                 .serviceId("at-basemap")
1479                 .layerName("bmapoverlay")
1480                 .visible(false))
1481             .addBaseLayerNodesItem(new AppTreeLayerNode()
1482                 .objectType("AppTreeLayerNode")
1483                 .id("lyr:at-basemap:orthofoto_2")
1484                 .serviceId("at-basemap")
1485                 .layerName("bmaporthofoto30cm")
1486                 .visible(false))
1487             .addBaseLayerNodesItem(new AppTreeLayerNode()
1488                 .objectType("AppTreeLayerNode")
1489                 .id("lyr:osm:xyz")
1490                 .serviceId("osm")
1491                 .layerName("xyz")
1492                 .visible(false)));
1493 
1494     applicationRepository.save(app);
1495 
1496     app = new Application()
1497         .setName("3d_utrecht")
1498         .setCrs("EPSG:3857")
1499         .setAuthorizationRules(ruleLoggedIn)
1500         .setTitle("3D Utrecht")
1501         .setInitialExtent(
1502             new Bounds().minx(558390d).miny(6818485d).maxx(566751d).maxy(6824036d))
1503         .setMaxExtent(
1504             new Bounds().minx(91467d).miny(6496479d).maxx(1037043d).maxy(7147453d))
1505         .setSettings(new AppSettings().uiSettings(new AppUiSettings().enable3D(true)))
1506         .setContentRoot(new AppContent()
1507             .addBaseLayerNodesItem(new AppTreeLevelNode()
1508                 .objectType("AppTreeLevelNode")
1509                 .id("root-base-layers")
1510                 .root(true)
1511                 .title("Base layers")
1512                 .childrenIds(List.of("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR", "lyr:osm:xyz")))
1513             .addBaseLayerNodesItem(new AppTreeLayerNode()
1514                 .objectType("AppTreeLayerNode")
1515                 .id("lyr:pdok-hwh-luchtfotorgb:Actueel_orthoHR")
1516                 .serviceId("pdok-hwh-luchtfotorgb")
1517                 .layerName("Actueel_orthoHR")
1518                 .visible(true))
1519             .addBaseLayerNodesItem(new AppTreeLayerNode()
1520                 .objectType("AppTreeLayerNode")
1521                 .id("lyr:osm:xyz")
1522                 .serviceId("osm")
1523                 .layerName("xyz")
1524                 .visible(false))
1525             .addLayerNodesItem(new AppTreeLevelNode()
1526                 .objectType("AppTreeLevelNode")
1527                 .id("root")
1528                 .root(true)
1529                 .title("Layers")
1530                 .childrenIds(List.of(
1531                     "lyr:3dbag_utrecht:tiles3d",
1532                     "lyr:snapshot-geoserver:postgis:begroeidterreindeel",
1533                     "lyr:3d_basisvoorziening_gebouwen_proxy:tiles3d",
1534                     "lyr:3d_utrecht_proxied_auth:tiles3d")))
1535             .addLayerNodesItem(new AppTreeLayerNode()
1536                 .objectType("AppTreeLayerNode")
1537                 .id("lyr:3dbag_utrecht:tiles3d")
1538                 .serviceId("3dbag_utrecht")
1539                 .layerName("tiles3d")
1540                 .visible(true))
1541             .addLayerNodesItem(new AppTreeLayerNode()
1542                 .objectType("AppTreeLayerNode")
1543                 .id("lyr:3d_basisvoorziening_gebouwen_proxy:tiles3d")
1544                 .serviceId("3d_basisvoorziening_gebouwen_proxy")
1545                 .layerName("tiles3d")
1546                 .visible(false))
1547             .addLayerNodesItem(new AppTreeLayerNode()
1548                 .objectType("AppTreeLayerNode")
1549                 .id("lyr:3d_utrecht_proxied_auth:tiles3d")
1550                 .serviceId("3d_utrecht_proxied_auth")
1551                 .layerName("tiles3d")
1552                 .visible(false))
1553             .addLayerNodesItem(new AppTreeLayerNode()
1554                 .objectType("AppTreeLayerNode")
1555                 .id("lyr:snapshot-geoserver:postgis:begroeidterreindeel")
1556                 .serviceId("snapshot-geoserver")
1557                 .layerName("postgis:begroeidterreindeel")
1558                 .visible(true))
1559             .addTerrainLayerNodesItem(new AppTreeLevelNode()
1560                 .objectType("AppTreeLevelNode")
1561                 .id("root-terrain-layers")
1562                 .root(true)
1563                 .title("Terrain Layers")
1564                 .childrenIds(List.of("lyr:ahn_terrain_model:quantizedmesh")))
1565             .addTerrainLayerNodesItem(new AppTreeLayerNode()
1566                 .objectType("AppTreeLayerNode")
1567                 .id("lyr:ahn_terrain_model:quantizedmesh")
1568                 .serviceId("ahn_terrain_model")
1569                 .layerName("quantizedmesh")
1570                 .visible(false)));
1571 
1572     applicationRepository.save(app);
1573 
1574     app = new Application()
1575         .setName("public-with-auth")
1576         .setTitle("Public app with one restricted layer in a group")
1577         .setCrs("EPSG:28992")
1578         .setAuthorizationRules(ruleAnonymousRead)
1579         .setInitialExtent(
1580             new Bounds().minx(130011d).miny(458031d).maxx(132703d).maxy(459995d))
1581         .setMaxExtent(
1582             new Bounds().minx(-285401d).miny(22598d).maxx(595401d).maxy(903401d))
1583         .setStyling(new AppStyling().logo(logo.getId().toString()))
1584         .setContentRoot(new AppContent()
1585             .addBaseLayerNodesItem(new AppTreeLevelNode()
1586                 .objectType("AppTreeLevelNode")
1587                 .id("root")
1588                 .root(true)
1589                 .title("Basemaps")
1590                 .childrenIds(List.of("lyr:openbasiskaart:osm")))
1591             .addBaseLayerNodesItem(new AppTreeLayerNode()
1592                 .objectType("AppTreeLayerNode")
1593                 .id("lyr:openbasiskaart:osm")
1594                 .serviceId("openbasiskaart")
1595                 .layerName("osm")
1596                 .visible(true))
1597             .addLayerNodesItem(new AppTreeLevelNode()
1598                 .objectType("AppTreeLevelNode")
1599                 .id("root")
1600                 .root(true)
1601                 .title("Application layers")
1602                 .childrenIds(List.of(
1603                     "lyr:snapshot-geoserver:postgis:kadastraal_perceel", "xpfhl34VmghkU12nP9Jer")))
1604             .addLayerNodesItem(new AppTreeLayerNode()
1605                 .objectType("AppTreeLayerNode")
1606                 .id("lyr:snapshot-geoserver:postgis:kadastraal_perceel")
1607                 .serviceId("snapshot-geoserver")
1608                 .layerName("postgis:kadastraal_perceel")
1609                 .visible(true))
1610             .addLayerNodesItem(new AppTreeLevelNode()
1611                 .id("xpfhl34VmghkU12nP9Jer")
1612                 .root(false)
1613                 .title("restricted")
1614                 .objectType("AppTreeLevelNode")
1615                 .childrenIds(List.of("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")))
1616             .addLayerNodesItem(new AppTreeLayerNode()
1617                 .objectType("AppTreeLayerNode")
1618                 .id("lyr:filtered-snapshot-geoserver:postgis:begroeidterreindeel")
1619                 .visible(true)
1620                 .serviceId("filtered-snapshot-geoserver")
1621                 .layerName("postgis:begroeidterreindeel")))
1622         .setSettings(new AppSettings());
1623 
1624     applicationRepository.save(app);
1625 
1626     Configuration config = new Configuration();
1627     config.setKey(Configuration.DEFAULT_APP);
1628     config.setValue("default");
1629     configurationRepository.save(config);
1630     config = new Configuration();
1631     config.setKey(Configuration.DEFAULT_BASE_APP);
1632     config.setValue("base");
1633     configurationRepository.save(config);
1634   }
1635 
1636   private void createConfigurationTestData() throws JsonProcessingException {
1637     Configuration config = new Configuration();
1638     config.setKey("test");
1639     config.setAvailableForViewer(true);
1640     config.setValue("test value");
1641     config.setJsonValue(new ObjectMapper().readTree("{ \"someProperty\": 1, \"nestedObject\": { \"num\": 42 } }"));
1642     configurationRepository.save(config);
1643   }
1644 
1645   @Transactional
1646   public void createSolrIndex() throws Exception {
1647     if (connectToSpatialDbs) {
1648       // flush() the repo because we need to make sure feature type testdata is fully stored
1649       // before creating the Solr index (which requires access to the feature type settings)
1650       featureSourceRepository.flush();
1651 
1652       logger.info("Creating Solr index");
1653       @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
1654       final String solrUrl = "http://" + (connectToSpatialDbsAtLocalhost ? "127.0.0.1" : "solr") + ":8983/solr/";
1655       this.solrService.setSolrUrl(solrUrl);
1656       SolrHelper solrHelper = new SolrHelper(this.solrService.getSolrClientForIndexing())
1657           .withBatchSize(solrBatchSize)
1658           .withGeometryValidationRule(solrGeometryValidationRule);
1659       GeoService geoService =
1660           geoServiceRepository.findById("snapshot-geoserver").orElseThrow();
1661       Application defaultApp = applicationRepository.findByName("default");
1662 
1663       TMFeatureType begroeidterreindeelFT = geoService.findFeatureTypeForLayer(
1664           geoService.findLayer("postgis:begroeidterreindeel"), featureSourceRepository);
1665 
1666       TMFeatureType wegdeelFT = geoService.findFeatureTypeForLayer(
1667           geoService.findLayer("sqlserver:wegdeel"), featureSourceRepository);
1668 
1669       TMFeatureType kadastraalPerceelFT = geoService.findFeatureTypeForLayer(
1670           geoService.findLayer("postgis:kadastraal_perceel"), featureSourceRepository);
1671 
1672       try (solrHelper) {
1673         SearchIndex begroeidterreindeelIndex = null;
1674         if (begroeidterreindeelFT != null) {
1675           begroeidterreindeelIndex = new SearchIndex()
1676               .setName("Begroeidterreindeel")
1677               .setFeatureTypeId(begroeidterreindeelFT.getId())
1678               .setSearchFieldsUsed(List.of("class", "plus_fysiekvoorkomen", "bronhouder"))
1679               .setSearchDisplayFieldsUsed(List.of("class", "plus_fysiekvoorkomen"));
1680           begroeidterreindeelIndex = searchIndexRepository.save(begroeidterreindeelIndex);
1681           begroeidterreindeelIndex = solrHelper.addFeatureTypeIndex(
1682               begroeidterreindeelIndex,
1683               begroeidterreindeelFT,
1684               featureSourceFactoryHelper,
1685               searchIndexRepository);
1686           begroeidterreindeelIndex = searchIndexRepository.save(begroeidterreindeelIndex);
1687         }
1688 
1689         SearchIndex kadastraalPerceelIndex = null;
1690         if (kadastraalPerceelFT != null) {
1691           kadastraalPerceelIndex = new SearchIndex()
1692               .setName("kadastraal_perceel")
1693               .setFeatureTypeId(kadastraalPerceelFT.getId())
1694               .setSearchFieldsUsed(List.of("aanduiding"))
1695               .setSearchDisplayFieldsUsed(List.of("aanduiding"));
1696           kadastraalPerceelIndex = searchIndexRepository.save(kadastraalPerceelIndex);
1697           kadastraalPerceelIndex = solrHelper.addFeatureTypeIndex(
1698               kadastraalPerceelIndex,
1699               kadastraalPerceelFT,
1700               featureSourceFactoryHelper,
1701               searchIndexRepository);
1702           kadastraalPerceelIndex = searchIndexRepository.save(kadastraalPerceelIndex);
1703         }
1704 
1705         SearchIndex wegdeelIndex = null;
1706         if (wegdeelFT != null) {
1707           wegdeelIndex = new SearchIndex()
1708               .setName("Wegdeel")
1709               .setFeatureTypeId(wegdeelFT.getId())
1710               .setSearchFieldsUsed(List.of(
1711                   "function_", "plus_fysiekvoorkomenwegdeel", "surfacematerial", "bronhouder"))
1712               .setSearchDisplayFieldsUsed(List.of("function_", "plus_fysiekvoorkomenwegdeel"));
1713           wegdeelIndex = searchIndexRepository.save(wegdeelIndex);
1714           wegdeelIndex = solrHelper.addFeatureTypeIndex(
1715               wegdeelIndex, wegdeelFT, featureSourceFactoryHelper, searchIndexRepository);
1716           wegdeelIndex = searchIndexRepository.save(wegdeelIndex);
1717         }
1718 
1719         featureSourceRepository
1720             .getByTitle("PostGIS")
1721             .flatMap(fs -> fs.getFeatureTypes().stream()
1722                 .filter(ft -> ft.getName().equals("bak"))
1723                 .findFirst())
1724             .ifPresent(ft -> {
1725               SearchIndex bak = new SearchIndex()
1726                   .setName("bak")
1727                   .setFeatureTypeId(ft.getId())
1728                   .setSearchFieldsUsed(List.of("gmlid", "identificatie", "plus_type"))
1729                   .setSearchDisplayFieldsUsed(List.of("gmlid", "plus_type", "bronhouder"));
1730               searchIndexRepository.save(bak);
1731               try {
1732                 bak = solrHelper.addFeatureTypeIndex(
1733                     bak, ft, featureSourceFactoryHelper, searchIndexRepository);
1734                 searchIndexRepository.save(bak);
1735               } catch (IOException | SolrServerException e) {
1736                 throw new RuntimeException(e);
1737               }
1738             });
1739 
1740         // creating a solr index of config this will/should fail because there is no primary key in
1741         // the FT
1742         featureSourceRepository
1743             .getByTitle("PostGIS OSM")
1744             .flatMap(fs -> fs.getFeatureTypes().stream()
1745                 .filter(ft -> ft.getName().equals("osm_roads"))
1746                 .findFirst())
1747             .ifPresent(ft -> {
1748               SearchIndex osm_no_pk = new SearchIndex()
1749                   .setName("osm_no_pk")
1750                   .setFeatureTypeId(ft.getId())
1751                   .setSearchFieldsUsed(List.of("landuse", "osm_id", "natural", "boundary"))
1752                   .setSearchDisplayFieldsUsed(
1753                       List.of("landuse", "osm_id", "natural", "amenity", "boundary"));
1754               searchIndexRepository.save(osm_no_pk);
1755             });
1756 
1757         AppTreeLayerNode begroeidTerreindeelLayerNode = defaultApp
1758             .getAllAppTreeLayerNode()
1759             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:postgis:begroeidterreindeel"))
1760             .findFirst()
1761             .orElse(null);
1762 
1763         if (begroeidTerreindeelLayerNode != null && begroeidterreindeelIndex != null) {
1764           defaultApp
1765               .getAppLayerSettings(begroeidTerreindeelLayerNode)
1766               .setSearchIndexId(begroeidterreindeelIndex.getId());
1767         }
1768 
1769         AppTreeLayerNode kadastraalPerceelLayerNode = defaultApp
1770             .getAllAppTreeLayerNode()
1771             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:postgis:kadastraal_perceel"))
1772             .findFirst()
1773             .orElse(null);
1774 
1775         if (kadastraalPerceelLayerNode != null && kadastraalPerceelIndex != null) {
1776           defaultApp
1777               .getAppLayerSettings(kadastraalPerceelLayerNode)
1778               .setSearchIndexId(kadastraalPerceelIndex.getId());
1779         }
1780 
1781         AppTreeLayerNode wegdeel = defaultApp
1782             .getAllAppTreeLayerNode()
1783             .filter(node -> node.getId().equals("lyr:snapshot-geoserver:sqlserver:wegdeel"))
1784             .findFirst()
1785             .orElse(null);
1786 
1787         if (wegdeel != null && wegdeelIndex != null) {
1788           defaultApp.getAppLayerSettings(wegdeel).setSearchIndexId(wegdeelIndex.getId());
1789         }
1790 
1791         applicationRepository.save(defaultApp);
1792       }
1793     }
1794   }
1795 
1796   private void createSearchIndexTasks() {
1797     logger.info("Creating search index tasks");
1798     List.of("Begroeidterreindeel", "kadastraal_perceel")
1799         .forEach(name -> searchIndexRepository.findByName(name).ifPresent(index -> {
1800           index.setSchedule(new TaskSchedule()
1801               /* hour */
1802               .cronExpression("0 0 0/1 1/1 * ? *")
1803               // /* 15 min */
1804               // .cronExpression("0 0/15 * 1/1 * ? *")
1805               .description("Update Solr index \"" + name + "\" every hour"));
1806           try {
1807             final UUID uuid = taskManagerService.createTask(
1808                 IndexTask.class,
1809                 new TMJobDataMap(Map.of(
1810                     Task.TYPE_KEY,
1811                     TaskType.INDEX,
1812                     Task.DESCRIPTION_KEY,
1813                     index.getSchedule().getDescription(),
1814                     IndexTask.INDEX_KEY,
1815                     index.getId().toString(),
1816                     Task.PRIORITY_KEY,
1817                     10)),
1818                 index.getSchedule().getCronExpression());
1819 
1820             index.getSchedule().setUuid(uuid);
1821             searchIndexRepository.save(index);
1822 
1823             logger.info("Created task to update Solr index with key: {}", uuid);
1824           } catch (SchedulerException e) {
1825             logger.error("Error creating scheduled solr index task", e);
1826           }
1827         }));
1828   }
1829 
1830   private void createPages() throws IOException {
1831     Upload logo = new Upload()
1832         .setCategory(Upload.CATEGORY_PORTAL_IMAGE)
1833         .setFilename("gradient.svg")
1834         .setMimeType("image/svg+xml")
1835         .setContent(new ClassPathResource("test/gradient-logo.svg").getContentAsByteArray())
1836         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
1837     uploadRepository.save(logo);
1838 
1839     Page about = new Page();
1840     about.setName("about");
1841     about.setType("page");
1842     about.setContent("About Tailormap");
1843     about.setContent("""
1844 # About Tailormap
1845 
1846 This is a page about *Tailormap*. It doesn't say much yet.
1847 """);
1848     pageRepository.save(about);
1849 
1850     Page page = new Page();
1851     page.setName("home");
1852     page.setType("page");
1853     page.setTitle("Tailormap - Home");
1854     page.setContent(
1855         """
1856 # Welcome to Tailormap!
1857 
1858 This page is only visible when you implement a frontend to display pages, or get it (including a simple CMS)
1859 from [B3Partners](https://www.b3partners.nl)!
1860 """);
1861     page.setClassName(null);
1862     page.setTiles(List.of(
1863         new PageTile()
1864             .id(UUID.randomUUID().toString())
1865             .title("Default app")
1866             .applicationId(Optional.ofNullable(applicationRepository.findByName("default"))
1867                 .map(Application::getId)
1868                 .orElse(null))
1869             .image(logo.getId().toString())
1870             .content("*Default app* tile content")
1871             .filterRequireAuthorization(false)
1872             .openInNewWindow(false),
1873         new PageTile()
1874             .id(UUID.randomUUID().toString())
1875             .title("Secured app")
1876             .applicationId(Optional.ofNullable(applicationRepository.findByName("secured"))
1877                 .map(Application::getId)
1878                 .orElse(null))
1879             .filterRequireAuthorization(true)
1880             .content("Secure app, only shown if user has authorization")
1881             .openInNewWindow(false),
1882         new PageTile()
1883             .id(UUID.randomUUID().toString())
1884             .title("Secured app (unfiltered)")
1885             .applicationId(Optional.ofNullable(applicationRepository.findByName("secured"))
1886                 .map(Application::getId)
1887                 .orElse(null))
1888             .filterRequireAuthorization(false)
1889             .content("Secure app, tile shown to everyone")
1890             .openInNewWindow(false),
1891         new PageTile()
1892             .id(UUID.randomUUID().toString())
1893             .title("About")
1894             .pageId(about.getId())
1895             .openInNewWindow(false),
1896         new PageTile()
1897             .id(UUID.randomUUID().toString())
1898             .title("B3Partners")
1899             .url("https://www.b3partners.nl/")
1900             .openInNewWindow(true)));
1901     pageRepository.save(page);
1902 
1903     Configuration c = new Configuration();
1904     c.setKey(HOME_PAGE);
1905     c.setValue(page.getId().toString());
1906     configurationRepository.save(c);
1907 
1908     List<MenuItem> globalMenuItems = List.of(
1909         new MenuItem().pageId(about.getId()).label("About").openInNewWindow(false),
1910         new MenuItem()
1911             .label("B3Partners website")
1912             .url("https://www.b3partners.nl/")
1913             .openInNewWindow(true)
1914             .exclusiveOnPageId(about.getId()));
1915     c = new Configuration();
1916     c.setKey(PORTAL_MENU);
1917     c.setJsonValue(new ObjectMapper().valueToTree(globalMenuItems));
1918     configurationRepository.save(c);
1919   }
1920 
1921   private void insertTestDrawing() {
1922     // note that the drawing uuid is hardcoded and used in the DrawingControllerIntegrationTest
1923     try {
1924       this.jdbcClient
1925           .sql(
1926               """
1927 INSERT INTO data.drawing (id,name,description,domain_data,"access",created_by,created_at,updated_by,updated_at,srid,"version") VALUES
1928 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid,'Testcase','A private access drawing that is inserted as part of the testdata','{"items": 1, "domain": "test drawings"}','private','tm-admin','2025-02-27 17:53:36.095164+01','tm-admin','2025-02-27 17:54:19.384961+01',28992,1);
1929 """)
1930           .update();
1931 
1932       this.jdbcClient
1933           .sql(
1934               """
1935 INSERT INTO data.drawing_feature (drawing_id,id,geometry,properties) VALUES
1936 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid, '394e0d4a-b374-4d00-94c8-1758ea70e9d9'::uuid, 'SRID=28992;POLYGON ((131982.01 458929.27, 131957.31 458755.45, 132072.39 458736.69, 132099.06 458910.51, 131982.01 458929.27))'::public.geometry, '{"type": "POLYGON", "style": {"label": "", "marker": "circle", "fillColor": "rgb(117, 117, 117)", "labelSize": 15, "labelColor": "rgb(0, 0, 0)", "markerSize": 5, "fillOpacity": 30, "strokeColor": "rgb(98, 54, 255)", "strokeWidth": 3, "strokeOpacity": 100, "markerRotation": 0, "markerFillColor": "rgb(98, 54, 255)", "labelOutlineColor": "rgb(189, 189, 189)", "markerStrokeColor": "rgb(98, 54, 255)", "markerStrokeWidth": 1}}'::jsonb),
1937 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid, '89fc320c-41a3-4635-8d98-39d822339692'::uuid, 'SRID=28992;POINT (131897.55 458971.24)'::public.geometry, '{"type": "CIRCLE", "style": {"label": "CIRCLE", "marker": "circle", "fillColor": "rgb(117, 117, 117)", "labelSize": 15, "labelColor": "rgb(0, 0, 0)", "markerSize": 5, "fillOpacity": 30, "strokeColor": "rgb(98, 54, 255)", "strokeWidth": 3, "strokeOpacity": 100, "markerRotation": 0, "markerFillColor": "rgb(98, 54, 255)", "labelOutlineColor": "rgb(189, 189, 189)", "markerStrokeColor": "rgb(98, 54, 255)", "markerStrokeWidth": 1}, "radius": 14.989999999990687}'::jsonb),
1938 ('38faa008-013e-49d4-9528-8f58c94d8791'::uuid, '5d6252cc-25b6-4a43-9053-0779b86895a7'::uuid, 'SRID=28992;LINESTRING (131920.76 458754.96, 131923.73 458783.1, 131930.15 458793.97, 131945.95 458912.98, 131967.68 458946.06)'::public.geometry, '{"type": "LINE", "style": {"label": "", "marker": "circle", "fillColor": "rgb(255, 82, 82)", "labelSize": 15, "labelColor": "rgb(0, 0, 0)", "markerSize": 5, "fillOpacity": 30, "strokeColor": "rgb(255, 82, 82)", "strokeWidth": 13, "strokeOpacity": 100, "markerRotation": 0, "markerFillColor": "rgb(98, 54, 255)", "labelOutlineColor": "rgb(189, 189, 189)", "markerStrokeColor": "rgb(98, 54, 255)", "markerStrokeWidth": 1}}'::jsonb)
1939 """)
1940           .update();
1941     } catch (Exception any) {
1942       logger.error("Error inserting test drawing in data schema, some tests may fail", any);
1943     }
1944   }
1945 
1946   private void insertDrawingStyle() throws IOException {
1947     Upload upload = new Upload()
1948         .setCategory(Upload.CATEGORY_DRAWING_STYLE_IMAGE)
1949         .setMimeType("image/svg+xml")
1950         .setFilename("ISO_7001_PI_PF_007.svg")
1951         .setContent(new ClassPathResource("test/ISO_7001_PI_PF_007.svg").getContentAsByteArray())
1952         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
1953     upload = uploadRepository.save(upload);
1954     UUID drinkwaterImageId = upload.getId();
1955 
1956     upload = new Upload()
1957         .setCategory(Upload.CATEGORY_DRAWING_STYLE_IMAGE)
1958         .setMimeType("image/svg+xml")
1959         .setFilename("lichtpunt.svg")
1960         .setContent(new ClassPathResource("test/lichtpunt.svg").getContentAsByteArray())
1961         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
1962     upload = uploadRepository.save(upload);
1963     UUID lichtpuntImageId = upload.getId();
1964 
1965     upload = new Upload()
1966         .setCategory(Upload.CATEGORY_DRAWING_STYLE_IMAGE)
1967         .setMimeType("image/svg+xml")
1968         .setFilename("ISO_7010_E003_-_First_aid_sign.svg")
1969         .setContent(new ClassPathResource("test/ISO_7010_E003_-_First_aid_sign.svg").getContentAsByteArray())
1970         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
1971     upload = uploadRepository.save(upload);
1972     UUID firstAidImageId = upload.getId();
1973 
1974     Properties props = new Properties();
1975     props.putAll(Map.of(
1976         "water-uuid", drinkwaterImageId.toString(),
1977         "lichtpunt-uuid", lichtpuntImageId.toString(),
1978         "first-aid-uuid", firstAidImageId.toString()));
1979 
1980     String drawingStyles =
1981         new ClassPathResource("test/object-drawing-style.json").getContentAsString(StandardCharsets.UTF_8);
1982     drawingStyles = new PropertyPlaceholderHelper("${", "}").replacePlaceholders(drawingStyles, props);
1983 
1984     // save the updated drawing style
1985     upload = new Upload()
1986         .setCategory(Upload.CATEGORY_DRAWING_STYLE)
1987         .setMimeType("application/json")
1988         .setFilename("object-drawing-style.json")
1989         .setContent(drawingStyles.getBytes(StandardCharsets.UTF_8))
1990         .setLastModified(OffsetDateTime.now(ZoneId.systemDefault()));
1991     uploadRepository.save(upload);
1992   }
1993 }