1# -*- coding: utf-8 -*-
2"""QGIS Unit tests for QgsServer WFS.
3
4From build dir, run: ctest -R PyQgsServerWFS -V
5
6
7.. note:: This program is free software; you can redistribute it and/or modify
8it under the terms of the GNU General Public License as published by
9the Free Software Foundation; either version 2 of the License, or
10(at your option) any later version.
11
12"""
13__author__ = 'René-Luc Dhont'
14__date__ = '19/09/2017'
15__copyright__ = 'Copyright 2017, The QGIS Project'
16
17import os
18
19# Needed on Qt 5 so that the serialization of XML is consistent among all executions
20os.environ['QT_HASH_SEED'] = '1'
21
22import re
23import urllib.request
24import urllib.parse
25import urllib.error
26
27from qgis.server import QgsServerRequest
28
29from qgis.testing import unittest
30from qgis.PyQt.QtCore import QSize
31from qgis.core import (
32    QgsVectorLayer,
33    QgsFeatureRequest,
34    QgsExpression,
35    QgsCoordinateReferenceSystem,
36    QgsCoordinateTransform,
37    QgsCoordinateTransformContext,
38    QgsGeometry,
39)
40
41import osgeo.gdal  # NOQA
42
43from test_qgsserver import QgsServerTestBase
44
45# Strip path and content length because path may vary
46RE_STRIP_UNCHECKABLE = br'MAP=[^"]+|Content-Length: \d+|timeStamp="[^"]+"'
47RE_ATTRIBUTES = br'[^>\s]+=[^>\s]+'
48
49
50class TestQgsServerWFS(QgsServerTestBase):
51    """QGIS Server WFS Tests"""
52
53    # Set to True in child classes to re-generate reference files for this class
54    regenerate_reference = False
55
56    def wfs_request_compare(self,
57                            request, version='',
58                            extra_query_string='',
59                            reference_base_name=None,
60                            project_file="test_project_wfs.qgs",
61                            requestMethod=QgsServerRequest.GetMethod,
62                            data=None):
63        project = self.testdata_path + project_file
64        assert os.path.exists(project), "Project file not found: " + project
65
66        query_string = '?MAP=%s&SERVICE=WFS&REQUEST=%s' % (
67            urllib.parse.quote(project), request)
68        if version:
69            query_string += '&VERSION=%s' % version
70
71        if extra_query_string:
72            query_string += '&%s' % extra_query_string
73
74        header, body = self._execute_request(
75            query_string, requestMethod=requestMethod, data=data)
76        self.assert_headers(header, body)
77        response = header + body
78
79        if reference_base_name is not None:
80            reference_name = reference_base_name
81        else:
82            reference_name = 'wfs_' + request.lower()
83
84        if version == '1.0.0':
85            reference_name += '_1_0_0'
86        reference_name += '.txt'
87
88        reference_path = self.testdata_path + reference_name
89
90        self.store_reference(reference_path, response)
91        f = open(reference_path, 'rb')
92        expected = f.read()
93        f.close()
94        response = re.sub(RE_STRIP_UNCHECKABLE, b'', response)
95        expected = re.sub(RE_STRIP_UNCHECKABLE, b'', expected)
96
97        self.assertXMLEqual(response, expected, msg="request %s failed.\n Query: %s" % (
98            query_string, request))
99        return header, body
100
101    def test_operation_not_supported(self):
102        qs = '?MAP=%s&SERVICE=WFS&VERSION=1.1.0&REQUEST=NotAValidRequest' % urllib.parse.quote(self.projectPath)
103        self._assert_status_code(501, qs)
104
105    def test_project_wfs(self):
106        """Test some WFS request"""
107
108        for request in ('GetCapabilities', 'DescribeFeatureType'):
109            self.wfs_request_compare(request)
110            self.wfs_request_compare(request, '1.0.0')
111
112    def wfs_getfeature_compare(self, requestid, request):
113
114        project = self.testdata_path + "test_project_wfs.qgs"
115        assert os.path.exists(project), "Project file not found: " + project
116
117        query_string = '?MAP=%s&SERVICE=WFS&VERSION=1.0.0&REQUEST=%s' % (
118            urllib.parse.quote(project), request)
119        header, body = self._execute_request(query_string)
120
121        if requestid == 'hits':
122            body = re.sub(br'timeStamp="\d+-\d+-\d+T\d+:\d+:\d+"',
123                          b'timeStamp="****-**-**T**:**:**"', body)
124
125        self.result_compare(
126            'wfs_getfeature_' + requestid + '.txt',
127            "request %s failed.\n Query: %s" % (
128                query_string,
129                request,
130            ),
131            header, body
132        )
133
134    def test_getfeature(self):
135
136        tests = []
137        tests.append(('nobbox', 'GetFeature&TYPENAME=testlayer'))
138        tests.append(
139            ('startindex2', 'GetFeature&TYPENAME=testlayer&STARTINDEX=2'))
140        tests.append(('limit2', 'GetFeature&TYPENAME=testlayer&MAXFEATURES=2'))
141        tests.append(
142            ('start1_limit1', 'GetFeature&TYPENAME=testlayer&MAXFEATURES=1&STARTINDEX=1'))
143        tests.append(
144            ('srsname', 'GetFeature&TYPENAME=testlayer&SRSNAME=EPSG:3857'))
145        tests.append(('sortby', 'GetFeature&TYPENAME=testlayer&SORTBY=id D'))
146        tests.append(('hits', 'GetFeature&TYPENAME=testlayer&RESULTTYPE=hits'))
147
148        for id, req in tests:
149            self.wfs_getfeature_compare(id, req)
150
151    def test_wfs_getcapabilities_100_url(self):
152        """Check that URL in GetCapabilities response is complete"""
153
154        # empty url in project
155        project = os.path.join(
156            self.testdata_path, "test_project_without_urls.qgs")
157        qs = "?" + "&".join(["%s=%s" % i for i in list({
158            "MAP": urllib.parse.quote(project),
159            "SERVICE": "WFS",
160            "VERSION": "1.0.0",
161            "REQUEST": "GetCapabilities"
162        }.items())])
163
164        r, h = self._result(self._execute_request(qs))
165
166        for item in str(r).split("\\n"):
167            if "onlineResource" in item:
168                self.assertEqual("onlineResource=\"?" in item, True)
169
170        # url well defined in query string
171        project = os.path.join(
172            self.testdata_path, "test_project_without_urls.qgs")
173        qs = "https://www.qgis-server.org?" + "&".join(["%s=%s" % i for i in list({
174            "MAP": urllib.parse.quote(project),
175            "SERVICE": "WFS",
176            "VERSION": "1.0.0",
177            "REQUEST": "GetCapabilities"
178        }.items())])
179
180        r, h = self._result(self._execute_request(qs))
181
182        for item in str(r).split("\\n"):
183            if "onlineResource" in item:
184                self.assertTrue(
185                    "onlineResource=\"https://www.qgis-server.org?" in item, True)
186
187        # url well defined in project
188        project = os.path.join(
189            self.testdata_path, "test_project_with_urls.qgs")
190        qs = "?" + "&".join(["%s=%s" % i for i in list({
191            "MAP": urllib.parse.quote(project),
192            "SERVICE": "WFS",
193            "VERSION": "1.0.0",
194            "REQUEST": "GetCapabilities"
195        }.items())])
196
197        r, h = self._result(self._execute_request(qs))
198
199        for item in str(r).split("\\n"):
200            if "onlineResource" in item:
201                self.assertEqual(
202                    "onlineResource=\"my_wfs_advertised_url\"" in item, True)
203
204    def result_compare(self, file_name, error_msg_header, header, body):
205
206        self.assert_headers(header, body)
207        response = header + body
208        reference_path = self.testdata_path + file_name
209        self.store_reference(reference_path, response)
210        f = open(reference_path, 'rb')
211        expected = f.read()
212        f.close()
213        response = re.sub(RE_STRIP_UNCHECKABLE, b'', response)
214        expected = re.sub(RE_STRIP_UNCHECKABLE, b'', expected)
215        self.assertXMLEqual(response, expected, msg="%s\n" %
216                            (error_msg_header))
217
218    def wfs_getfeature_post_compare(self, requestid, request):
219
220        project = self.testdata_path + "test_project_wfs.qgs"
221        assert os.path.exists(project), "Project file not found: " + project
222
223        query_string = '?MAP={}'.format(urllib.parse.quote(project))
224        header, body = self._execute_request(
225            query_string, requestMethod=QgsServerRequest.PostMethod, data=request.encode('utf-8'))
226
227        self.result_compare(
228            'wfs_getfeature_{}.txt'.format(requestid),
229            "GetFeature in POST for '{}' failed.".format(requestid),
230            header, body,
231        )
232
233    def test_getfeature_post(self):
234        tests = []
235
236        template = """<?xml version="1.0" encoding="UTF-8"?>
237<wfs:GetFeature service="WFS" version="1.0.0" {} xmlns:wfs="http://www.opengis.net/wfs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd">
238  <wfs:Query typeName="testlayer" xmlns:feature="http://www.qgis.org/gml">
239    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
240      <ogc:BBOX>
241        <ogc:PropertyName>geometry</ogc:PropertyName>
242        <gml:Envelope xmlns:gml="http://www.opengis.net/gml">
243          <gml:lowerCorner>8 44</gml:lowerCorner>
244          <gml:upperCorner>9 45</gml:upperCorner>
245        </gml:Envelope>
246      </ogc:BBOX>
247    </ogc:Filter>
248  </wfs:Query>
249</wfs:GetFeature>
250"""
251        tests.append(('nobbox_post', template.format("")))
252        tests.append(('startindex2_post', template.format('startIndex="2"')))
253        tests.append(('limit2_post', template.format('maxFeatures="2"')))
254        tests.append(('start1_limit1_post', template.format(
255            'startIndex="1" maxFeatures="1"')))
256
257        srsTemplate = """<?xml version="1.0" encoding="UTF-8"?>
258<wfs:GetFeature service="WFS" version="1.0.0" {} xmlns:wfs="http://www.opengis.net/wfs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd">
259  <wfs:Query typeName="testlayer" srsName="EPSG:3857" xmlns:feature="http://www.qgis.org/gml">
260    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
261      <ogc:BBOX>
262        <ogc:PropertyName>geometry</ogc:PropertyName>
263        <gml:Envelope xmlns:gml="http://www.opengis.net/gml">
264          <gml:lowerCorner>890555.92634619 5465442.18332275</gml:lowerCorner>
265          <gml:upperCorner>1001875.41713946 5621521.48619207</gml:upperCorner>
266        </gml:Envelope>
267      </ogc:BBOX>
268    </ogc:Filter>
269  </wfs:Query>
270</wfs:GetFeature>
271"""
272        tests.append(('srsname_post', srsTemplate.format("")))
273
274        # Issue https://github.com/qgis/QGIS/issues/36398
275        # Check get feature within polygon having srsName=EPSG:4326 (same as the project/layer)
276        within4326FilterTemplate = """<?xml version="1.0" encoding="UTF-8"?>
277<wfs:GetFeature service="WFS" version="1.0.0" {} xmlns:wfs="http://www.opengis.net/wfs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd">
278  <wfs:Query typeName="testlayer" srsName="EPSG:4326" xmlns:feature="http://www.qgis.org/gml">
279    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
280      <Within>
281        <PropertyName>geometry</PropertyName>
282        <Polygon xmlns="http://www.opengis.net/gml" srsName="EPSG:4326">
283          <exterior>
284            <LinearRing>
285              <posList srsDimension="2">
286                8.20344131 44.90137909
287                8.20347748 44.90137909
288                8.20347748 44.90141005
289                8.20344131 44.90141005
290                8.20344131 44.90137909
291              </posList>
292            </LinearRing>
293          </exterior>
294        </Polygon>
295      </Within>
296    </ogc:Filter>
297  </wfs:Query>
298</wfs:GetFeature>
299"""
300        tests.append(('within4326FilterTemplate_post', within4326FilterTemplate.format("")))
301
302        # Check get feature within polygon having srsName=EPSG:3857 (different from the project/layer)
303        # The coordinates are converted from the one in 4326
304        within3857FilterTemplate = """<?xml version="1.0" encoding="UTF-8"?>
305<wfs:GetFeature service="WFS" version="1.0.0" {} xmlns:wfs="http://www.opengis.net/wfs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd">
306  <wfs:Query typeName="testlayer" srsName="EPSG:3857" xmlns:feature="http://www.qgis.org/gml">
307    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
308      <Within>
309        <PropertyName>geometry</PropertyName>
310        <Polygon xmlns="http://www.opengis.net/gml" srsName="EPSG:3857">
311          <exterior>
312            <LinearRing>
313              <posList srsDimension="2">
314              913202.90938171 5606008.98136456
315              913206.93580769 5606008.98136456
316              913206.93580769 5606013.84701639
317              913202.90938171 5606013.84701639
318              913202.90938171 5606008.98136456
319              </posList>
320            </LinearRing>
321          </exterior>
322        </Polygon>
323      </Within>
324    </ogc:Filter>
325  </wfs:Query>
326</wfs:GetFeature>
327"""
328        tests.append(('within3857FilterTemplate_post', within3857FilterTemplate.format("")))
329
330        srsTwoLayersTemplate = """<?xml version="1.0" encoding="UTF-8"?>
331<wfs:GetFeature service="WFS" version="1.0.0" {} xmlns:wfs="http://www.opengis.net/wfs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd">
332  <wfs:Query typeName="testlayer" srsName="EPSG:3857" xmlns:feature="http://www.qgis.org/gml">
333    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
334      <ogc:BBOX>
335        <ogc:PropertyName>geometry</ogc:PropertyName>
336        <gml:Envelope xmlns:gml="http://www.opengis.net/gml">
337          <gml:lowerCorner>890555.92634619 5465442.18332275</gml:lowerCorner>
338          <gml:upperCorner>1001875.41713946 5621521.48619207</gml:upperCorner>
339        </gml:Envelope>
340      </ogc:BBOX>
341    </ogc:Filter>
342  </wfs:Query>
343  <wfs:Query typeName="testlayer" srsName="EPSG:3857" xmlns:feature="http://www.qgis.org/gml">
344    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
345      <ogc:BBOX>
346        <ogc:PropertyName>geometry</ogc:PropertyName>
347        <gml:Envelope xmlns:gml="http://www.opengis.net/gml">
348          <gml:lowerCorner>890555.92634619 5465442.18332275</gml:lowerCorner>
349          <gml:upperCorner>1001875.41713946 5621521.48619207</gml:upperCorner>
350        </gml:Envelope>
351      </ogc:BBOX>
352    </ogc:Filter>
353  </wfs:Query>
354</wfs:GetFeature>
355"""
356        tests.append(('srs_two_layers_post', srsTwoLayersTemplate.format("")))
357
358        sortTemplate = """<?xml version="1.0" encoding="UTF-8"?>
359<wfs:GetFeature service="WFS" version="1.0.0" {} xmlns:wfs="http://www.opengis.net/wfs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd">
360  <wfs:Query typeName="testlayer" xmlns:feature="http://www.qgis.org/gml">
361    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
362      <ogc:BBOX>
363        <ogc:PropertyName>geometry</ogc:PropertyName>
364        <gml:Envelope xmlns:gml="http://www.opengis.net/gml">
365          <gml:lowerCorner>8 44</gml:lowerCorner>
366          <gml:upperCorner>9 45</gml:upperCorner>
367        </gml:Envelope>
368      </ogc:BBOX>
369    </ogc:Filter>
370    <ogc:SortBy>
371      <ogc:SortProperty>
372        <ogc:PropertyName>id</ogc:PropertyName>
373        <ogc:SortOrder>DESC</ogc:SortOrder>
374      </ogc:SortProperty>
375    </ogc:SortBy>
376  </wfs:Query>
377</wfs:GetFeature>
378"""
379        tests.append(('sortby_post', sortTemplate.format("")))
380
381        andTemplate = """<?xml version="1.0" encoding="UTF-8"?>
382<wfs:GetFeature service="WFS" version="1.0.0" {} xmlns:wfs="http://www.opengis.net/wfs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd">
383  <wfs:Query typeName="testlayer" srsName="EPSG:3857" xmlns:feature="http://www.qgis.org/gml">
384    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
385      <ogc:And>
386        <ogc:PropertyIsGreaterThan>
387          <ogc:PropertyName>id</ogc:PropertyName>
388          <ogc:Literal>1</ogc:Literal>
389        </ogc:PropertyIsGreaterThan>
390        <ogc:PropertyIsLessThan>
391          <ogc:PropertyName>id</ogc:PropertyName>
392          <ogc:Literal>3</ogc:Literal>
393        </ogc:PropertyIsLessThan>
394      </ogc:And>
395    </ogc:Filter>
396  </wfs:Query>
397</wfs:GetFeature>
398"""
399        tests.append(('and_post', andTemplate.format("")))
400
401        andBboxTemplate = """<?xml version="1.0" encoding="UTF-8"?>
402<wfs:GetFeature service="WFS" version="1.0.0" {} xmlns:wfs="http://www.opengis.net/wfs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd">
403  <wfs:Query typeName="testlayer" srsName="EPSG:3857" xmlns:feature="http://www.qgis.org/gml">
404    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
405      <ogc:And>
406        <ogc:BBOX>
407          <ogc:PropertyName>geometry</ogc:PropertyName>
408          <gml:Envelope xmlns:gml="http://www.opengis.net/gml">
409            <gml:lowerCorner>890555.92634619 5465442.18332275</gml:lowerCorner>
410            <gml:upperCorner>1001875.41713946 5621521.48619207</gml:upperCorner>
411          </gml:Envelope>
412        </ogc:BBOX>
413        <ogc:PropertyIsGreaterThan>
414          <ogc:PropertyName>id</ogc:PropertyName>
415          <ogc:Literal>1</ogc:Literal>
416        </ogc:PropertyIsGreaterThan>
417        <ogc:PropertyIsLessThan>
418          <ogc:PropertyName>id</ogc:PropertyName>
419          <ogc:Literal>3</ogc:Literal>
420        </ogc:PropertyIsLessThan>
421      </ogc:And>
422    </ogc:Filter>
423  </wfs:Query>
424</wfs:GetFeature>
425"""
426        tests.append(('bbox_inside_and_post', andBboxTemplate.format("")))
427
428        # With namespace
429        template = """<?xml version="1.0" encoding="UTF-8"?>
430<wfs:GetFeature service="WFS" version="1.0.0" {} xmlns:wfs="http://www.opengis.net/wfs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd">
431  <wfs:Query typeName="feature:testlayer" xmlns:feature="http://www.qgis.org/gml">
432    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
433      <ogc:BBOX>
434        <ogc:PropertyName>geometry</ogc:PropertyName>
435        <gml:Envelope xmlns:gml="http://www.opengis.net/gml">
436          <gml:lowerCorner>8 44</gml:lowerCorner>
437          <gml:upperCorner>9 45</gml:upperCorner>
438        </gml:Envelope>
439      </ogc:BBOX>
440    </ogc:Filter>
441  </wfs:Query>
442</wfs:GetFeature>
443"""
444        tests.append(('nobbox_post', template.format("")))
445
446        template = """<?xml version="1.0" encoding="UTF-8"?>
447<wfs:GetFeature service="WFS" version="1.0.0" {} xmlns:wfs="http://www.opengis.net/wfs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengis.net/wfs http://schemas.opengis.net/wfs/1.1.0/wfs.xsd">
448  <wfs:Query typeName="testlayer" xmlns="http://www.qgis.org/gml">
449    <ogc:Filter xmlns:ogc="http://www.opengis.net/ogc">
450      <ogc:BBOX>
451        <ogc:PropertyName>geometry</ogc:PropertyName>
452        <gml:Envelope xmlns:gml="http://www.opengis.net/gml">
453          <gml:lowerCorner>8 44</gml:lowerCorner>
454          <gml:upperCorner>9 45</gml:upperCorner>
455        </gml:Envelope>
456      </ogc:BBOX>
457    </ogc:Filter>
458  </wfs:Query>
459</wfs:GetFeature>
460"""
461        tests.append(('nobbox_post', template.format("")))
462
463        for id, req in tests:
464            self.wfs_getfeature_post_compare(id, req)
465
466    def test_getFeatureBBOX(self):
467        """Test with (1.1.0) and without (1.0.0) CRS"""
468
469        # Tests without CRS
470        self.wfs_request_compare(
471            "GetFeature", '1.0.0', "TYPENAME=testlayer&RESULTTYPE=hits&BBOX=8.20347,44.901471,8.2035354,44.901493", 'wfs_getFeature_1_0_0_bbox_1_feature')
472        self.wfs_request_compare(
473            "GetFeature", '1.0.0', "TYPENAME=testlayer&RESULTTYPE=hits&BBOX=8.203127,44.9012765,8.204138,44.901632", 'wfs_getFeature_1_0_0_bbox_3_feature')
474
475        # Tests with CRS
476        self.wfs_request_compare(
477            "GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=8.20347,44.901471,8.2035354,44.901493,EPSG:4326", 'wfs_getFeature_1_0_0_epsgbbox_1_feature')
478        self.wfs_request_compare(
479            "GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=8.203127,44.9012765,8.204138,44.901632,EPSG:4326", 'wfs_getFeature_1_0_0_epsgbbox_3_feature')
480        self.wfs_request_compare(
481            "GetFeature", '1.1.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=8.20347,44.901471,8.2035354,44.901493,EPSG:4326", 'wfs_getFeature_1_1_0_epsgbbox_1_feature')
482        self.wfs_request_compare(
483            "GetFeature", '1.1.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=8.203127,44.9012765,8.204138,44.901632,EPSG:4326", 'wfs_getFeature_1_1_0_epsgbbox_3_feature')
484
485        self.wfs_request_compare("GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=913144,5605992,913303,5606048,EPSG:3857",
486                                 'wfs_getFeature_1_0_0_epsgbbox_3_feature_3857')
487        self.wfs_request_compare("GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=913206,5606024,913213,5606026,EPSG:3857",
488                                 'wfs_getFeature_1_0_0_epsgbbox_1_feature_3857')
489        self.wfs_request_compare("GetFeature", '1.1.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=913144,5605992,913303,5606048,EPSG:3857",
490                                 'wfs_getFeature_1_1_0_epsgbbox_3_feature_3857')
491        self.wfs_request_compare("GetFeature", '1.1.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=913206,5606024,913213,5606026,EPSG:3857",
492                                 'wfs_getFeature_1_1_0_epsgbbox_1_feature_3857')
493
494        self.wfs_request_compare("GetFeature", '1.0.0', "SRSNAME=EPSG:3857&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=913144,5605992,913303,5606048,EPSG:3857",
495                                 'wfs_getFeature_1_0_0_epsgbbox_3_feature_3857')
496        self.wfs_request_compare("GetFeature", '1.0.0', "SRSNAME=EPSG:3857&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=913206,5606024,913213,5606026,EPSG:3857",
497                                 'wfs_getFeature_1_0_0_epsgbbox_1_feature_3857')
498        self.wfs_request_compare("GetFeature", '1.1.0', "SRSNAME=EPSG:3857&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=913144,5605992,913303,5606048,EPSG:3857",
499                                 'wfs_getFeature_1_1_0_epsgbbox_3_feature_3857')
500        self.wfs_request_compare("GetFeature", '1.1.0', "SRSNAME=EPSG:3857&TYPENAME=testlayer&RESULTTYPE=hits&BBOX=913206,5606024,913213,5606026,EPSG:3857",
501                                 'wfs_getFeature_1_1_0_epsgbbox_1_feature_3857')
502
503    def test_getFeatureFeatureId(self):
504        """Test GetFeature with featureid"""
505
506        self.wfs_request_compare(
507            "GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&FEATUREID=testlayer.0", 'wfs_getFeature_1_0_0_featureid_0')
508
509    def test_getFeatureFeature11urn(self):
510        """Test GetFeature with SRSNAME urn:ogc:def:crs:EPSG::4326"""
511
512        self.wfs_request_compare(
513            "GetFeature", '1.1.0', "SRSNAME=urn:ogc:def:crs:EPSG::4326&TYPENAME=testlayer&FEATUREID=testlayer.0", 'wfs_getFeature_1_1_0_featureid_0_1_1_0')
514
515    def test_getFeature_EXP_FILTER_regression_20927(self):
516        """Test expressions with EXP_FILTER"""
517
518        self.wfs_request_compare(
519            "GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&FEATUREID=testlayer.0&EXP_FILTER=\"name\"='one'", 'wfs_getFeature_1_0_0_EXP_FILTER_FID_one')
520        # Note that FEATUREID takes precedence over EXP_FILTER and the filter is completely ignored when FEATUREID is set
521        self.wfs_request_compare(
522            "GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&FEATUREID=testlayer.0&EXP_FILTER=\"name\"='two'", 'wfs_getFeature_1_0_0_EXP_FILTER_FID_one')
523        self.wfs_request_compare(
524            "GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&EXP_FILTER=\"name\"='two'", 'wfs_getFeature_1_0_0_EXP_FILTER_two')
525        self.wfs_request_compare(
526            "GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&EXP_FILTER=\"name\"=concat('tw', 'o')", 'wfs_getFeature_1_0_0_EXP_FILTER_two')
527        # Syntax ok but function does not exist
528        self.wfs_request_compare("GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&EXP_FILTER=\"name\"=invalid_expression('tw', 'o')",
529                                 'wfs_getFeature_1_0_0_EXP_FILTER_invalid_expression')
530        # Syntax error in exp
531        self.wfs_request_compare("GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&EXP_FILTER=\"name\"=concat('tw, 'o')",
532                                 'wfs_getFeature_1_0_0_EXP_FILTER_syntax_error')
533        # BBOX gml expressions
534        self.wfs_request_compare("GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&EXP_FILTER=intersects($geometry, geom_from_gml('<gml:Box> <gml:coordinates cs=\",\" ts=\" \">8.20344750430995617,44.9013881888184514 8.20347909100379269,44.90140004005827024</gml:coordinates></gml:Box>'))", 'wfs_getFeature_1_0_0_EXP_FILTER_gml_bbox_three')
535        self.wfs_request_compare("GetFeature", '1.0.0', "SRSNAME=EPSG:4326&TYPENAME=testlayer&EXP_FILTER=intersects($geometry, geom_from_gml('<gml:Box> <gml:coordinates cs=\",\" ts=\" \">8.20348458304175665,44.90147459621791626 8.20351616973559317,44.9014864474577351</gml:coordinates></gml:Box>'))", 'wfs_getFeature_1_0_0_EXP_FILTER_gml_bbox_one')
536
537    def test_describeFeatureType(self):
538        """Test DescribeFeatureType with TYPENAME filters"""
539
540        project_file = "test_project_wms_grouped_layers.qgs"
541        self.wfs_request_compare("DescribeFeatureType", '1.0.0', "TYPENAME=as_areas&",
542                                 'wfs_describeFeatureType_1_0_0_typename_as_areas', project_file=project_file)
543        self.wfs_request_compare("DescribeFeatureType", '1.1.0', "TYPENAME=as_areas&",
544                                 'wfs_describeFeatureType_1_1_0_typename_as_areas', project_file=project_file)
545        self.wfs_request_compare("DescribeFeatureType", '1.0.0', "",
546                                 'wfs_describeFeatureType_1_0_0_typename_empty', project_file=project_file)
547        self.wfs_request_compare("DescribeFeatureType", '1.1.0', "",
548                                 'wfs_describeFeatureType_1_1_0_typename_empty', project_file=project_file)
549        self.wfs_request_compare("DescribeFeatureType", '1.0.0', "TYPENAME=does_not_exist&",
550                                 'wfs_describeFeatureType_1_0_0_typename_wrong', project_file=project_file)
551        self.wfs_request_compare("DescribeFeatureType", '1.1.0', "TYPENAME=does_not_exist&",
552                                 'wfs_describeFeatureType_1_1_0_typename_wrong', project_file=project_file)
553
554    def test_describeFeatureTypeVirtualFields(self):
555        """Test DescribeFeatureType with virtual fields: bug GH-29767"""
556
557        project_file = "bug_gh29767_double_vfield.qgs"
558        self.wfs_request_compare("DescribeFeatureType", '1.1.0', "",
559                                 'wfs_describeFeatureType_1_1_0_virtual_fields', project_file=project_file)
560
561    def test_getFeatureFeature_0_nulls(self):
562        """Test that 0 and null in integer columns are reported correctly"""
563
564        # Test transactions with 0 and nulls
565
566        post_data = """<?xml version="1.0" ?>
567<wfs:Transaction service="WFS" version="{version}"
568  xmlns:ogc="http://www.opengis.net/ogc"
569  xmlns:wfs="http://www.opengis.net/wfs"
570  xmlns:gml="http://www.opengis.net/gml">
571   <wfs:Update typeName="cdb_lines">
572      <wfs:Property>
573         <wfs:Name>{field}</wfs:Name>
574         <wfs:Value>{value}</wfs:Value>
575      </wfs:Property>
576      <fes:Filter>
577         <fes:FeatureId fid="cdb_lines.22"/>
578      </fes:Filter>
579   </wfs:Update>
580</wfs:Transaction>
581            """
582
583        def _round_trip(value, field, version='1.1.0'):
584            """Set a value on fid 22 and field and check it back"""
585
586            encoded_data = post_data.format(field=field, value=value, version=version).encode('utf8')
587            # Strip the field if NULL
588            if value is None:
589                encoded_data = encoded_data.replace(b'<wfs:Value>None</wfs:Value>', b'')
590
591            header, body = self._execute_request("?MAP=%s&SERVICE=WFS&VERSION=%s" % (
592                self.testdata_path + 'test_project_wms_grouped_layers.qgs', version), QgsServerRequest.PostMethod, encoded_data)
593            if version == '1.0.0':
594                self.assertTrue(b'<SUCCESS/>' in body, body)
595            else:
596                self.assertTrue(b'<totalUpdated>1</totalUpdated>' in body, body)
597            header, body = self._execute_request("?MAP=%s&SERVICE=WFS&REQUEST=GetFeature&TYPENAME=cdb_lines&FEATUREID=cdb_lines.22" % (
598                self.testdata_path + 'test_project_wms_grouped_layers.qgs'))
599            if value is not None:
600                xml_value = '<qgs:{0}>{1}</qgs:{0}>'.format(field, value).encode('utf8')
601                self.assertTrue(xml_value in body, "%s not found in body" % xml_value)
602            else:
603                xml_value = '<qgs:{0}>'.format(field).encode('utf8')
604                self.assertFalse(xml_value in body)
605            # Check the backend
606            vl = QgsVectorLayer(
607                self.testdata_path + 'test_project_wms_grouped_layers.gpkg|layername=cdb_lines', 'vl', 'ogr')
608            self.assertTrue(vl.isValid())
609            self.assertEqual(
610                str(vl.getFeature(22)[field]), value if value is not None else 'NULL')
611
612        for version in ('1.0.0', '1.1.0'):
613            _round_trip('0', 'id_long', version)
614            _round_trip('12345', 'id_long', version)
615            _round_trip('0', 'id', version)
616            _round_trip('12345', 'id', version)
617            _round_trip(None, 'id', version)
618            _round_trip(None, 'id_long', version)
619
620            # "name" is NOT NULL: try to set it to empty string
621            _round_trip('', 'name', version)
622            # Then NULL
623            data = post_data.format(field='name', value='', version=version).encode('utf8')
624            encoded_data = data.replace(b'<wfs:Value></wfs:Value>', b'')
625            header, body = self._execute_request("?MAP=%s&SERVICE=WFS" % (
626                self.testdata_path + 'test_project_wms_grouped_layers.qgs'), QgsServerRequest.PostMethod, encoded_data)
627            if version == '1.0.0':
628                self.assertTrue(b'<ERROR/>' in body, body)
629            else:
630                self.assertTrue(b'<totalUpdated>0</totalUpdated>' in body)
631            self.assertTrue(b'<Message>NOT NULL constraint error on layer \'cdb_lines\', field \'name\'</Message>' in body, body)
632
633    def test_describeFeatureTypeGeometryless(self):
634        """Test DescribeFeatureType with geometryless tables - bug GH-30381"""
635
636        project_file = "test_project_geometryless_gh30381.qgs"
637        self.wfs_request_compare("DescribeFeatureType", '1.1.0',
638                                 reference_base_name='wfs_describeFeatureType_1_1_0_geometryless',
639                                 project_file=project_file)
640
641    def test_getFeatureFeatureIdJson(self):
642        """Test GetFeature with featureid JSON format and various content types"""
643
644        for ct in ('GeoJSON', 'application/vnd.geo+json', 'application/json', 'application/geo+json'):
645            self.wfs_request_compare(
646                "GetFeature",
647                '1.0.0',
648                ("OUTPUTFORMAT=%s" % ct)
649                + "&SRSNAME=EPSG:4326&TYPENAME=testlayer&FEATUREID=testlayer.0",
650                'wfs_getFeature_1_0_0_featureid_0_json')
651
652    def test_insert_srsName(self):
653        """Test srsName is respected when insering"""
654
655        post_data = """
656        <Transaction xmlns="http://www.opengis.net/wfs" xsi:schemaLocation="http://www.qgis.org/gml http://localhost:8000/?SERVICE=WFS&amp;REQUEST=DescribeFeatureType&amp;VERSION=1.0.0&amp;TYPENAME=as_symbols" service="WFS" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{version}" xmlns:gml="http://www.opengis.net/gml">
657            <Insert xmlns="http://www.opengis.net/wfs">
658                <as_symbols xmlns="http://www.qgis.org/gml">
659                <name xmlns="http://www.qgis.org/gml">{name}</name>
660                <geometry xmlns="http://www.qgis.org/gml">
661                    <gml:Point srsName="{srsName}">
662                    <gml:coordinates cs="," ts=" ">{coordinates}</gml:coordinates>
663                    </gml:Point>
664                </geometry>
665                </as_symbols>
666            </Insert>
667        </Transaction>
668        """
669
670        project = self.testdata_path + \
671            "test_project_wms_grouped_layers.qgs"
672        assert os.path.exists(project), "Project file not found: " + project
673
674        query_string = '?SERVICE=WFS&MAP={}'.format(
675            urllib.parse.quote(project))
676        request = post_data.format(
677            name='4326-test1',
678            version='1.1.0',
679            srsName='EPSG:4326',
680            coordinates='10.67,52.48'
681        )
682        header, body = self._execute_request(
683            query_string, requestMethod=QgsServerRequest.PostMethod, data=request.encode('utf-8'))
684
685        # Verify
686        vl = QgsVectorLayer(self.testdata_path + 'test_project_wms_grouped_layers.gpkg|layername=as_symbols', 'as_symbols')
687        self.assertTrue(vl.isValid())
688        feature = next(vl.getFeatures(QgsFeatureRequest(QgsExpression('"name" = \'4326-test1\''))))
689        geom = feature.geometry()
690
691        tr = QgsCoordinateTransform(QgsCoordinateReferenceSystem.fromEpsgId(4326), vl.crs(), QgsCoordinateTransformContext())
692
693        geom_4326 = QgsGeometry.fromWkt('point( 10.67 52.48)')
694        geom_4326.transform(tr)
695        self.assertEqual(geom.asWkt(0), geom_4326.asWkt(0))
696
697        # Now: insert a feature in layer's CRS
698        request = post_data.format(
699            name='25832-test1',
700            version='1.1.0',
701            srsName='EPSG:25832',
702            coordinates='613412,5815738'
703        )
704        header, body = self._execute_request(
705            query_string, requestMethod=QgsServerRequest.PostMethod, data=request.encode('utf-8'))
706
707        feature = next(vl.getFeatures(QgsFeatureRequest(QgsExpression('"name" = \'25832-test1\''))))
708        geom = feature.geometry()
709        self.assertEqual(geom.asWkt(0), geom_4326.asWkt(0))
710
711        # Tests for inverted axis issue GH #36584
712        # Cleanup
713        self.assertTrue(vl.startEditing())
714        vl.selectByExpression('"name" LIKE \'4326-test%\'')
715        vl.deleteSelectedFeatures()
716        self.assertTrue(vl.commitChanges())
717
718        self.i = 0
719
720        def _test(version, srsName, lat_lon=False):
721            self.i += 1
722            name = '4326-test_%s' % self.i
723            request = post_data.format(
724                name=name,
725                version=version,
726                srsName=srsName,
727                coordinates='52.48,10.67' if lat_lon else '10.67,52.48'
728            )
729            header, body = self._execute_request(
730                query_string, requestMethod=QgsServerRequest.PostMethod, data=request.encode('utf-8'))
731            feature = next(vl.getFeatures(QgsFeatureRequest(QgsExpression('"name" = \'%s\'' % name))))
732            geom = feature.geometry()
733            self.assertEqual(geom.asWkt(0), geom_4326.asWkt(0), "Transaction Failed: %s , %s, lat_lon=%s" % (version, srsName, lat_lon))
734
735        _test('1.1.0', 'urn:ogc:def:crs:EPSG::4326', lat_lon=True)
736        _test('1.1.0', 'http://www.opengis.net/def/crs/EPSG/0/4326', lat_lon=True)
737        _test('1.1.0', 'http://www.opengis.net/gml/srs/epsg.xml#4326', lat_lon=False)
738        _test('1.1.0', 'EPSG:4326', lat_lon=False)
739
740        _test('1.0.0', 'urn:ogc:def:crs:EPSG::4326', lat_lon=True)
741        _test('1.0.0', 'http://www.opengis.net/def/crs/EPSG/0/4326', lat_lon=True)
742        _test('1.0.0', 'http://www.opengis.net/gml/srs/epsg.xml#4326', lat_lon=False)
743        _test('1.0.0', 'EPSG:4326', lat_lon=False)
744
745        def _test_getFeature(version, srsName, lat_lon=False):
746            # Now get the feature through WFS using BBOX filter
747            bbox = QgsGeometry.fromWkt('point( 10.7006 52.4317)').boundingBox()
748            bbox.grow(0.0001)
749            bbox_text = "%s,%s,%s,%s" % ((bbox.yMinimum(), bbox.xMinimum(), bbox.yMaximum(), bbox.xMaximum()) if lat_lon else (bbox.xMinimum(), bbox.yMinimum(), bbox.xMaximum(), bbox.yMaximum()))
750            req = query_string + '&REQUEST=GetFeature&VERSION={version}&TYPENAME=as_symbols&SRSNAME={srsName}&BBOX={bbox},{srsName}'.format(version=version, srsName=srsName, bbox=bbox_text)
751            header, body = self._execute_request(req)
752            self.assertTrue(b'gid>7' in body, "GetFeature Failed: %s , %s, lat_lon=%s" % (version, srsName, lat_lon))
753
754        _test_getFeature('1.1.0', 'urn:ogc:def:crs:EPSG::4326', lat_lon=True)
755        _test_getFeature('1.1.0', 'EPSG:4326', lat_lon=False)
756
757        _test_getFeature('1.0.0', 'urn:ogc:def:crs:EPSG::4326', lat_lon=True)
758        _test_getFeature('1.0.0', 'EPSG:4326', lat_lon=False)
759
760        # Cleanup
761        self.assertTrue(vl.startEditing())
762        vl.selectByExpression('"name" LIKE \'4326-test%\'')
763        vl.deleteSelectedFeatures()
764        self.assertTrue(vl.commitChanges())
765
766
767if __name__ == '__main__':
768    unittest.main()
769